From 6fde82017d498aab6c4c02f428dcbc8d70b0bb23 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 07:39:17 -0700 Subject: [PATCH 01/14] feat: apply ChatGPT pre-MCP 0.2.1 overlay --- BACKLOG.md | 31 +- CHANGED_FILES.md | 149 +- CHANGELOG.md | 60 +- CLEANUP_AND_REMOVAL_AUDIT.md | 72 +- FEATURE_TEST_LEDGER.md | 73 +- MANIFEST.sha256 | 342 +- README.md | 51 +- REMOVED_PATHS.txt | 20 +- SESSION_RESTORE.md | 132 +- VERSION | 2 +- agent-workflow.yaml | 2 +- docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md | 6 + docs/ARCHITECTURE.md | 148 +- docs/COMMAND_REFERENCE.md | 137 +- ...BLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md | 10 +- docs/MCP_IMPLEMENTATION_CHECKLIST.md | 36 + docs/MCP_SERVER_DECISION.md | 18 +- docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md | 339 ++ docs/MCP_SERVER_IMPLEMENTATION_REPORT.md | 102 +- docs/MCP_THREAT_MODEL.md | 84 + docs/MCP_TOOL_AND_RESOURCE_CATALOG.md | 73 + docs/PROVIDER_EVIDENCE_RESEARCH.md | 125 + docs/SECURITY_TRUST_BOUNDARIES.md | 34 + docs/WORKFLOW_FOUNDATIONS_PLAN.md | 156 +- docs/diagrams/REPOSITORY_CHART_PACK.md | 499 +++ docs/diagrams/authority-hierarchy.mmd | 12 + docs/diagrams/evidence-erd.mmd | 16 + docs/diagrams/mcp-target-architecture.mmd | 10 + docs/diagrams/provider-evidence.mmd | 9 + docs/diagrams/workflow-scheduler.mmd | 18 + .../BKL-003-RESEARCH-TICKET-COMPLETION.md | 62 + .../BKL-003-TICKET-COMPLETION.md | 65 + .../FINAL_CRITICAL_REVIEW.md | 67 + docs/execution-evidence/PHASE_GATE_REPORT.md | 2 + .../WF-10-TICKET-COMPLETION.md | 61 + .../WF-11-TICKET-COMPLETION.md | 61 + .../WF-12-TICKET-COMPLETION.md | 61 + .../WF-20-TICKET-COMPLETION.md | 62 + .../WF-21-TICKET-COMPLETION.md | 62 + .../WF-22-TICKET-COMPLETION.md | 63 + .../WORKFLOW_BENCHMARK_PHASE_GATE.md | 56 + docs/man/agent-workflow-mcp.1 | 17 + docs/man/agent-workflow-workflow.1 | 40 + docs/man/agent-workflow.1 | 65 + .../MANIFEST.sha256 | 2 +- .../README.md | 2 + prompt-packs/mcp-server-next/MANIFEST.sha256 | 8 +- prompt-packs/mcp-server-next/README.md | 14 +- prompt-packs/mcp-server-next/pack.yaml | 2 +- .../mcp-server-next/phase-0/README.md | 2 +- .../mcp-server-next/references/README.md | 2 +- pyproject.toml | 6 +- schemas/execution-metrics.schema.json | 23 + schemas/provider-evidence.schema.json | 60 + schemas/routing-advice.schema.json | 27 + schemas/run-provenance.schema.json | 276 +- schemas/trial-evidence.schema.json | 57 + schemas/workflow-event.schema.json | 52 + schemas/workflow-input-bindings.schema.json | 37 + schemas/workflow-node-binding.schema.json | 20 + schemas/workflow-node-result.schema.json | 83 + schemas/workflow-receipt.schema.json | 54 + schemas/workflow-run.schema.json | 164 + schemas/workflow-snapshot.schema.json | 193 ++ schemas/workflow-status.schema.json | 126 + scripts/audit-release-assets.py | 495 +-- skills/agent-workflow-orchestrator/SKILL.md | 19 + skills/delegated-implementation/SKILL.md | 6 + skills/phase-gate-review/SKILL.md | 6 + skills/prompt-pack-builder/SKILL.md | 4 + src/agent_workflow/__init__.py | 2 +- src/agent_workflow/approval.py | 70 + src/agent_workflow/bindings.py | 203 ++ src/agent_workflow/cli.py | 69 +- src/agent_workflow/doctor.py | 2 +- src/agent_workflow/eval/compare.py | 19 +- src/agent_workflow/eval/scoring.py | 42 +- src/agent_workflow/eval/trials.py | 148 +- src/agent_workflow/ledger.py | 6 +- src/agent_workflow/lifecycle.py | 229 +- .../{SDK_SNAPSHOT.md => SDK_DEPENDENCY.md} | 8 +- .../mcp/sdk/.git-blame-ignore-revs | 5 - src/agent_workflow/mcp/sdk/.gitattribute | 2 - src/agent_workflow/mcp/sdk/.gitignore | 175 - .../mcp/sdk/.pre-commit-config.yaml | 63 - src/agent_workflow/mcp/sdk/CLAUDE.md | 137 - src/agent_workflow/mcp/sdk/CODE_OF_CONDUCT.md | 128 - src/agent_workflow/mcp/sdk/CONTRIBUTING.md | 110 - .../mcp/sdk/DEPENDENCY_POLICY.md | 30 - src/agent_workflow/mcp/sdk/LICENSE | 21 - src/agent_workflow/mcp/sdk/README.md | 192 -- src/agent_workflow/mcp/sdk/RELEASE.md | 13 - src/agent_workflow/mcp/sdk/ROADMAP.md | 22 - src/agent_workflow/mcp/sdk/SECURITY.md | 15 - src/agent_workflow/mcp/sdk/VERSIONING.md | 40 - src/agent_workflow/mcp/sdk/docs/api.md | 1 - .../mcp/sdk/docs/authorization.md | 177 - src/agent_workflow/mcp/sdk/docs/client.md | 410 --- .../mcp/sdk/docs/experimental/index.md | 46 - .../mcp/sdk/docs/experimental/tasks-client.md | 363 --- .../mcp/sdk/docs/experimental/tasks-server.md | 622 ---- .../mcp/sdk/docs/experimental/tasks.md | 189 -- src/agent_workflow/mcp/sdk/docs/index.md | 72 - .../mcp/sdk/docs/installation.md | 31 - .../mcp/sdk/docs/low-level-server.md | 490 --- src/agent_workflow/mcp/sdk/docs/protocol.md | 153 - src/agent_workflow/mcp/sdk/docs/server.md | 1831 ----------- src/agent_workflow/mcp/sdk/docs/testing.md | 78 - src/agent_workflow/mcp/sdk/mkdocs.yml | 134 - src/agent_workflow/mcp/sdk/pyproject.toml | 230 -- .../mcp/sdk/src/mcp/__init__.py | 135 - .../mcp/sdk/src/mcp/cli/__init__.py | 6 - .../mcp/sdk/src/mcp/cli/claude.py | 148 - src/agent_workflow/mcp/sdk/src/mcp/cli/cli.py | 488 --- .../mcp/sdk/src/mcp/client/__init__.py | 0 .../mcp/sdk/src/mcp/client/__main__.py | 85 - .../mcp/sdk/src/mcp/client/auth/__init__.py | 21 - .../mcp/sdk/src/mcp/client/auth/exceptions.py | 10 - .../mcp/client/auth/extensions/__init__.py | 0 .../auth/extensions/client_credentials.py | 487 --- .../mcp/sdk/src/mcp/client/auth/oauth2.py | 627 ---- .../mcp/sdk/src/mcp/client/auth/utils.py | 336 -- .../src/mcp/client/experimental/__init__.py | 9 - .../mcp/client/experimental/task_handlers.py | 290 -- .../sdk/src/mcp/client/experimental/tasks.py | 224 -- .../mcp/sdk/src/mcp/client/session.py | 633 ---- .../mcp/sdk/src/mcp/client/session_group.py | 447 --- .../mcp/sdk/src/mcp/client/sse.py | 163 - .../mcp/sdk/src/mcp/client/stdio/__init__.py | 278 -- .../mcp/sdk/src/mcp/client/streamable_http.py | 722 ----- .../mcp/sdk/src/mcp/client/websocket.py | 94 - .../mcp/sdk/src/mcp/os/__init__.py | 1 - .../mcp/sdk/src/mcp/os/posix/__init__.py | 1 - .../mcp/sdk/src/mcp/os/posix/utilities.py | 60 - .../mcp/sdk/src/mcp/os/win32/__init__.py | 1 - .../mcp/sdk/src/mcp/os/win32/utilities.py | 338 -- src/agent_workflow/mcp/sdk/src/mcp/py.typed | 0 .../mcp/sdk/src/mcp/server/__init__.py | 5 - .../mcp/sdk/src/mcp/server/__main__.py | 50 - .../mcp/sdk/src/mcp/server/auth/__init__.py | 3 - .../mcp/sdk/src/mcp/server/auth/errors.py | 5 - .../src/mcp/server/auth/handlers/__init__.py | 3 - .../src/mcp/server/auth/handlers/authorize.py | 224 -- .../src/mcp/server/auth/handlers/metadata.py | 29 - .../src/mcp/server/auth/handlers/register.py | 136 - .../src/mcp/server/auth/handlers/revoke.py | 91 - .../sdk/src/mcp/server/auth/handlers/token.py | 241 -- .../sdk/src/mcp/server/auth/json_response.py | 10 - .../mcp/server/auth/middleware/__init__.py | 3 - .../server/auth/middleware/auth_context.py | 48 - .../mcp/server/auth/middleware/bearer_auth.py | 152 - .../mcp/server/auth/middleware/client_auth.py | 115 - .../mcp/sdk/src/mcp/server/auth/provider.py | 305 -- .../mcp/sdk/src/mcp/server/auth/routes.py | 253 -- .../mcp/sdk/src/mcp/server/auth/settings.py | 30 - .../mcp/sdk/src/mcp/server/elicitation.py | 190 -- .../src/mcp/server/experimental/__init__.py | 12 - .../server/experimental/request_context.py | 285 -- .../server/experimental/session_features.py | 226 -- .../mcp/server/experimental/task_context.py | 614 ---- .../experimental/task_result_handler.py | 243 -- .../src/mcp/server/experimental/task_scope.py | 75 - .../mcp/server/experimental/task_support.py | 128 - .../sdk/src/mcp/server/fastmcp/__init__.py | 11 - .../sdk/src/mcp/server/fastmcp/exceptions.py | 21 - .../mcp/server/fastmcp/prompts/__init__.py | 4 - .../src/mcp/server/fastmcp/prompts/base.py | 183 -- .../src/mcp/server/fastmcp/prompts/manager.py | 60 - .../mcp/server/fastmcp/resources/__init__.py | 23 - .../src/mcp/server/fastmcp/resources/base.py | 50 - .../fastmcp/resources/resource_manager.py | 115 - .../mcp/server/fastmcp/resources/templates.py | 122 - .../src/mcp/server/fastmcp/resources/types.py | 203 -- .../mcp/sdk/src/mcp/server/fastmcp/server.py | 1354 -------- .../src/mcp/server/fastmcp/tools/__init__.py | 4 - .../sdk/src/mcp/server/fastmcp/tools/base.py | 126 - .../mcp/server/fastmcp/tools/tool_manager.py | 93 - .../mcp/server/fastmcp/utilities/__init__.py | 1 - .../fastmcp/utilities/context_injection.py | 68 - .../server/fastmcp/utilities/func_metadata.py | 541 ---- .../mcp/server/fastmcp/utilities/logging.py | 43 - .../src/mcp/server/fastmcp/utilities/types.py | 101 - .../sdk/src/mcp/server/lowlevel/__init__.py | 3 - .../src/mcp/server/lowlevel/experimental.py | 342 -- .../mcp/server/lowlevel/func_inspection.py | 54 - .../src/mcp/server/lowlevel/helper_types.py | 11 - .../mcp/sdk/src/mcp/server/lowlevel/server.py | 830 ----- .../mcp/sdk/src/mcp/server/models.py | 20 - .../mcp/sdk/src/mcp/server/session.py | 701 ---- .../mcp/sdk/src/mcp/server/sse.py | 274 -- .../mcp/sdk/src/mcp/server/stdio.py | 88 - .../mcp/sdk/src/mcp/server/streamable_http.py | 1086 ------- .../src/mcp/server/streamable_http_manager.py | 343 -- .../sdk/src/mcp/server/transport_security.py | 127 - .../mcp/sdk/src/mcp/server/validation.py | 104 - .../mcp/sdk/src/mcp/server/websocket.py | 89 - .../mcp/sdk/src/mcp/shared/__init__.py | 0 .../mcp/sdk/src/mcp/shared/_httpx_utils.py | 87 - .../mcp/sdk/src/mcp/shared/auth.py | 177 - .../mcp/sdk/src/mcp/shared/auth_utils.py | 80 - .../mcp/sdk/src/mcp/shared/context.py | 32 - .../mcp/sdk/src/mcp/shared/exceptions.py | 71 - .../src/mcp/shared/experimental/__init__.py | 7 - .../mcp/shared/experimental/tasks/__init__.py | 12 - .../shared/experimental/tasks/capabilities.py | 115 - .../mcp/shared/experimental/tasks/context.py | 101 - .../mcp/shared/experimental/tasks/helpers.py | 181 -- .../tasks/in_memory_task_store.py | 219 -- .../experimental/tasks/message_queue.py | 241 -- .../mcp/shared/experimental/tasks/polling.py | 45 - .../mcp/shared/experimental/tasks/resolver.py | 60 - .../mcp/shared/experimental/tasks/store.py | 156 - .../mcp/sdk/src/mcp/shared/memory.py | 98 - .../mcp/sdk/src/mcp/shared/message.py | 50 - .../mcp/sdk/src/mcp/shared/metadata_utils.py | 45 - .../mcp/sdk/src/mcp/shared/progress.py | 58 - .../mcp/sdk/src/mcp/shared/response_router.py | 63 - .../mcp/sdk/src/mcp/shared/session.py | 552 ---- .../src/mcp/shared/tool_name_validation.py | 129 - .../mcp/sdk/src/mcp/shared/version.py | 3 - src/agent_workflow/mcp/sdk/src/mcp/types.py | 1999 ------------ src/agent_workflow/mcp/sdk/uv.lock | 2844 ----------------- src/agent_workflow/metrics.py | 28 +- src/agent_workflow/provider_evidence.py | 423 +++ src/agent_workflow/receipts.py | 328 +- src/agent_workflow/routing.py | 89 + src/agent_workflow/runner.py | 58 +- src/agent_workflow/scheduler.py | 627 ++++ src/agent_workflow/sessions.py | 33 +- src/agent_workflow/util.py | 29 +- src/agent_workflow/workflow.py | 775 +++++ src/agent_workflow/workflow_receipt.py | 285 ++ src/agent_workflow/workflow_service.py | 182 ++ src/agent_workflow/workflow_templates.py | 82 + tests/run_fixtures.py | 3 + tests/test_approval.py | 414 +++ tests/test_bindings.py | 179 ++ tests/test_cli_parsing.py | 46 + tests/test_eval_reporting.py | 4 +- tests/test_eval_trials.py | 62 +- tests/test_lifecycle.py | 70 +- tests/test_metrics.py | 25 + tests/test_provider_evidence.py | 157 + tests/test_receipts.py | 56 +- tests/test_release_assets.py | 40 +- tests/test_routing.py | 93 + tests/test_runner_generation.py | 3 +- tests/test_session_launch.py | 39 + tests/test_workflow.py | 1702 ++++++++++ tests/test_workflow_receipt.py | 378 +++ tests/test_workflow_templates.py | 92 + 251 files changed, 11131 insertions(+), 30699 deletions(-) create mode 100644 docs/MCP_IMPLEMENTATION_CHECKLIST.md create mode 100644 docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md create mode 100644 docs/MCP_THREAT_MODEL.md create mode 100644 docs/MCP_TOOL_AND_RESOURCE_CATALOG.md create mode 100644 docs/PROVIDER_EVIDENCE_RESEARCH.md create mode 100644 docs/SECURITY_TRUST_BOUNDARIES.md create mode 100644 docs/diagrams/REPOSITORY_CHART_PACK.md create mode 100644 docs/diagrams/authority-hierarchy.mmd create mode 100644 docs/diagrams/evidence-erd.mmd create mode 100644 docs/diagrams/mcp-target-architecture.mmd create mode 100644 docs/diagrams/provider-evidence.mmd create mode 100644 docs/diagrams/workflow-scheduler.mmd create mode 100644 docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/BKL-003-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/FINAL_CRITICAL_REVIEW.md create mode 100644 docs/execution-evidence/WF-10-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/WF-11-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/WF-12-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/WF-20-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/WF-21-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/WF-22-TICKET-COMPLETION.md create mode 100644 docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md create mode 100644 docs/man/agent-workflow-mcp.1 create mode 100644 docs/man/agent-workflow-workflow.1 create mode 100644 docs/man/agent-workflow.1 create mode 100644 schemas/provider-evidence.schema.json create mode 100644 schemas/routing-advice.schema.json create mode 100644 schemas/trial-evidence.schema.json create mode 100644 schemas/workflow-event.schema.json create mode 100644 schemas/workflow-input-bindings.schema.json create mode 100644 schemas/workflow-node-binding.schema.json create mode 100644 schemas/workflow-node-result.schema.json create mode 100644 schemas/workflow-receipt.schema.json create mode 100644 schemas/workflow-run.schema.json create mode 100644 schemas/workflow-snapshot.schema.json create mode 100644 schemas/workflow-status.schema.json create mode 100644 src/agent_workflow/approval.py create mode 100644 src/agent_workflow/bindings.py rename src/agent_workflow/mcp/{SDK_SNAPSHOT.md => SDK_DEPENDENCY.md} (67%) delete mode 100644 src/agent_workflow/mcp/sdk/.git-blame-ignore-revs delete mode 100644 src/agent_workflow/mcp/sdk/.gitattribute delete mode 100644 src/agent_workflow/mcp/sdk/.gitignore delete mode 100644 src/agent_workflow/mcp/sdk/.pre-commit-config.yaml delete mode 100644 src/agent_workflow/mcp/sdk/CLAUDE.md delete mode 100644 src/agent_workflow/mcp/sdk/CODE_OF_CONDUCT.md delete mode 100644 src/agent_workflow/mcp/sdk/CONTRIBUTING.md delete mode 100644 src/agent_workflow/mcp/sdk/DEPENDENCY_POLICY.md delete mode 100644 src/agent_workflow/mcp/sdk/LICENSE delete mode 100644 src/agent_workflow/mcp/sdk/README.md delete mode 100644 src/agent_workflow/mcp/sdk/RELEASE.md delete mode 100644 src/agent_workflow/mcp/sdk/ROADMAP.md delete mode 100644 src/agent_workflow/mcp/sdk/SECURITY.md delete mode 100644 src/agent_workflow/mcp/sdk/VERSIONING.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/api.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/authorization.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/client.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/experimental/index.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/experimental/tasks-client.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/experimental/tasks-server.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/experimental/tasks.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/index.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/installation.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/low-level-server.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/protocol.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/server.md delete mode 100644 src/agent_workflow/mcp/sdk/docs/testing.md delete mode 100644 src/agent_workflow/mcp/sdk/mkdocs.yml delete mode 100644 src/agent_workflow/mcp/sdk/pyproject.toml delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/cli/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/cli/claude.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/cli/cli.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/__main__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/auth/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/auth/exceptions.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/client_credentials.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/auth/oauth2.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/auth/utils.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/experimental/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/experimental/task_handlers.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/experimental/tasks.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/session.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/session_group.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/sse.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/stdio/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/streamable_http.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/client/websocket.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/os/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/os/posix/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/os/posix/utilities.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/os/win32/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/os/win32/utilities.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/py.typed delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/__main__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/errors.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/authorize.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/metadata.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/register.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/revoke.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/token.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/json_response.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/auth_context.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/bearer_auth.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/client_auth.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/provider.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/routes.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/auth/settings.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/elicitation.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/request_context.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/session_features.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_context.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_result_handler.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_scope.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_support.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/exceptions.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/base.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/manager.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/base.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/resource_manager.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/templates.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/types.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/server.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/base.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/tool_manager.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/context_injection.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/func_metadata.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/logging.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/types.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/experimental.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/func_inspection.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/helper_types.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/server.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/models.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/session.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/sse.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/stdio.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http_manager.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/transport_security.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/validation.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/server/websocket.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/_httpx_utils.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/auth.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/auth_utils.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/context.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/exceptions.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/__init__.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/capabilities.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/context.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/helpers.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/in_memory_task_store.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/message_queue.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/polling.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/resolver.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/store.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/memory.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/message.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/metadata_utils.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/progress.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/response_router.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/session.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/tool_name_validation.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/shared/version.py delete mode 100644 src/agent_workflow/mcp/sdk/src/mcp/types.py delete mode 100644 src/agent_workflow/mcp/sdk/uv.lock create mode 100644 src/agent_workflow/provider_evidence.py create mode 100644 src/agent_workflow/routing.py create mode 100644 src/agent_workflow/scheduler.py create mode 100644 src/agent_workflow/workflow.py create mode 100644 src/agent_workflow/workflow_receipt.py create mode 100644 src/agent_workflow/workflow_service.py create mode 100644 src/agent_workflow/workflow_templates.py create mode 100644 tests/test_approval.py create mode 100644 tests/test_bindings.py create mode 100644 tests/test_provider_evidence.py create mode 100644 tests/test_routing.py create mode 100644 tests/test_workflow.py create mode 100644 tests/test_workflow_receipt.py create mode 100644 tests/test_workflow_templates.py diff --git a/BACKLOG.md b/BACKLOG.md index 774dbd7..b53f45f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -24,15 +24,16 @@ plans and implementation reports are not parallel task trackers. | BKL-001 | P0 | ready | Add durable per-consumer control-log cursors and idempotent handling/disposition. Prove restart recovery, duplicate delivery safety, and cursor advancement only after handling succeeds. | [research: Stage A and Priority 2](docs/Durable_Orchestration_Delivery_Benchmarks.md#stage-a--single-host-tmux) | | BKL-006 | P0 | done | Make `agent-workflow` operationally discoverable to agents: add an orchestration skill, connect existing skills to CLI/runbooks/protocols, define native-agent versus durable-run boundaries, and install/test supported discovery roots. | [P0 task breakdown](docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md) | | BKL-002 | P0 | ready | Research, define, and implement executor-specific late steering for detached/non-interactive runs, starting with Codex and documenting the equivalent Claude capability boundary. A running agent must consume a post-launch steer (including a progress request) without restart, then emit correlated accepted, delivered, applied or rejected, and terminal evidence. Prove replay/idempotency and unavailable-adapter behavior; never infer delivery from logs, prose, tmux state, or terminal keystrokes. | [late-steering implementation ticket](docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md#late-steering-adapter-implementation-ticket) | -| BKL-003 | P1 | ready | Seal bounded raw executor stream evidence before normalization and add provider adapters that explicitly label usage as `delta`, `cumulative`, or `terminal`. Calibrate cached-token, reasoning-token, retry, and cost behavior against each supported executor. | [research: evidence principles and usage envelope](docs/Durable_Orchestration_Delivery_Benchmarks.md#evidence-principles) | +| BKL-003 | P1 | done | Seal bounded raw executor stream evidence before normalization and add provider adapters that explicitly label usage as `delta`, `cumulative`, or `terminal`. Implement cached/cache-write/reasoning mappings, fail-closed mode semantics, retry provenance, and provider/local cost separation. | [provider evidence research and implementation](docs/PROVIDER_EVIDENCE_RESEARCH.md) | | BKL-004 | P1 | ready | Run a controlled real-executor deterministic cohort. Pin executor/model/environment/tool policy, record capability calibration, retain raw and sealed evidence, publish an explicit baseline/candidate manifest with exclusions, and run `agent-workflow eval compare`. | [research: cohort protocol](docs/Durable_Orchestration_Delivery_Benchmarks.md#real-executor-cohort-protocol) | -| BKL-005 | P1 | ready | Extend trial evidence only where a sealed provider receipt proves it: source digests, retry/re-steer/error accounting, provider-billed versus locally estimated cost, currency rules, and incomplete-trial rejection. Add schema and comparison tests for every new field. | [research: immutable trial evidence and cost rules](docs/Durable_Orchestration_Delivery_Benchmarks.md#immutable-trial-evidence-schema) | +| BKL-005 | P1 | done | Extend trial evidence only where a sealed provider receipt proves it: source digests, retry/re-steer/error accounting, provider-billed versus locally estimated cost, currency rules, incomplete-trial rejection, and comparison validity. | [provider evidence research and implementation](docs/PROVIDER_EVIDENCE_RESEARCH.md) | | BKL-007 | P1 | ready | Add opt-in, installer-owned host routing enforcement: preserve existing hooks, block only exact raw delegation launch patterns, provide an audited break-glass path, and test Codex rules/instructions separately from Claude Code `PreToolUse` hooks. | [global routing decision](docs/GLOBAL_AGENT_ROUTING.md) | | BKL-008 | P1 | done | Track bounded durable context and assignment history for interactive agents; require explicit completion, rank same-worktree candidates, and restrict automatic reuse to exact ticket/retry lineage with correlated acknowledgement. | [interactive reuse](README.md#reusing-an-interactive-agent) | +| BKL-009 | P0 | done | Complete the 0.2.1 post-release critical review: repair scheduler reconciliation/capacity/authority, retry replay and descendant recovery, immutable workflow storage/journal handling, score-set verification, provider-evidence ambiguity handling, projections, tests, documentation, and release assets. | [0.2.1 critical review](docs/execution-evidence/FINAL_CRITICAL_REVIEW.md) | -### Workflow foundations (must complete before remaining MCP mutation work) +### Workflow foundations (completed in 0.2.0) -The scoped architecture and explicit non-targets are defined in [Workflow Foundations Plan](docs/WORKFLOW_FOUNDATIONS_PLAN.md). Remaining implementation is executable through [workflow-foundations-next](prompt-packs/workflow-foundations-next/README.md). The exact prompt-pack ticket IDs below are canonical backlog tasks. +The implemented architecture and explicit non-targets are recorded in [Workflow Foundations](docs/WORKFLOW_FOUNDATIONS_PLAN.md). The ticket IDs below remain canonical history and evidence anchors. | ID | Priority | State | Depends on | Work and exit evidence | Reference | |---|---|---|---|---|---| @@ -41,23 +42,23 @@ The scoped architecture and explicit non-targets are defined in [Workflow Founda | WF-00 | P0 | done | WF-002 | Define the minimal workflow snapshot, event, node-state, retry-lineage, and service contracts. Exit with versioned schemas/contracts and focused validation tests. | [ticket](prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md) | | WF-01 | P0 | done | WF-00 | Implement restart-safe dependency scheduling over the existing launch service with bounded parallelism and no alternate executor path. Exit with scheduler service tests covering dependency eligibility, failures, retries, and concurrency bounds. | [ticket](prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md) | | WF-02 | P0 | done | WF-01 | Add workflow CLI/status/resume behavior and prove restart reconstruction from durable records. Exit with CLI, recovery, and interrupted-run tests. | [ticket](prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md) | -| WF-10 | P1 | ready | WF-02 | Add receipt-backed approval gates that unblock only after a valid immutable review disposition for the expected revision. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md) | -| WF-11 | P1 | ready | WF-10 | Add bounded JSON Pointer result binding from validated child results, with type/size limits and no arbitrary file reads. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md) | -| WF-12 | P1 | ready | WF-11 | Seal aggregate workflow receipts that commit to the workflow snapshot, event stream, node bindings, child final receipts, approvals, and terminal disposition. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md) | -| WF-20 | P1 | ready | WF-12 | Add only the three authorized reusable graph templates: pipeline, bounded parallel review with fan-in, and implementation followed by independent review. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md) | -| WF-21 | P1 | ready | WF-20 | Add deterministic, explainable routing advice that remains subordinate to existing class, executor, model, and no-go policy enforcement. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md) | -| WF-22 | P1 | ready | WF-21 | Perform the integration, security, documentation, release-asset, and full-suite review for the complete workflow foundation. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md) | -| WF-006 | P2 | deferred | BKL-003, BKL-004, BKL-005 | Consider evidence-derived routing recommendations only after sealed, comparable executor cohorts exist. No online learning or vector-memory dependency. | [routing boundary](docs/WORKFLOW_FOUNDATIONS_PLAN.md#5-explainable-routing-advice) | +| WF-10 | P1 | done | WF-02 | Add receipt-backed approval gates that unblock only after a valid immutable review disposition for the expected revision. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md) | +| WF-11 | P1 | done | WF-10 | Add bounded JSON Pointer result binding from validated child results, with type/size limits and no arbitrary file reads. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md) | +| WF-12 | P1 | done | WF-11 | Seal aggregate workflow receipts that commit to the workflow snapshot, event stream, node bindings, child final receipts, approvals, and terminal disposition. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md) | +| WF-20 | P1 | done | WF-12 | Add only the three authorized reusable graph templates: pipeline, bounded parallel review with fan-in, and implementation followed by independent review. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md) | +| WF-21 | P1 | done | WF-20 | Add deterministic, explainable routing advice that remains subordinate to existing class, executor, model, and no-go policy enforcement. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md) | +| WF-22 | P1 | done | WF-21 | Perform the integration, security, documentation, release-asset, and full-suite review for the complete workflow foundation. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md) | +| WF-006 | P2 | deferred | BKL-003, BKL-004, BKL-005 | Consider evidence-derived routing recommendations only after sealed, comparable executor cohorts exist. No online learning or vector-memory dependency. | [routing boundary](docs/WORKFLOW_FOUNDATIONS_PLAN.md#explainable-routing-advice) | -### MCP server work (remaining mutation work follows WF-22) +### MCP server work -The read-only local stdio MCP adapter is already implemented and remains valid. Do not implement the remaining mutation surface until `WF-22` is complete; MCP must wrap stable workflow and lifecycle services rather than create parallel orchestration semantics. +The read-only local stdio MCP adapter is implemented. WF-22 is complete, so MCP-003 is ready for a separately scoped implementation. MCP must wrap stable workflow and lifecycle services rather than create parallel orchestration semantics. | ID | Priority | State | Depends on | Work and exit evidence | Reference | |---|---|---|---|---|---| | MCP-001 | P1 | done | — | Audit reusable domain seams and define typed MCP request/result contracts without changing CLI lifecycle semantics. MCP and CLI invoke shared service functions. | [implementation report](docs/MCP_SERVER_IMPLEMENTATION_REPORT.md) | | MCP-002 | P1 | done | MCP-001 | Add the optional pinned official Python SDK dependency and local stdio server with bounded read-only run/pack resources, traversal/redaction controls, capability negotiation, and tests. | [implementation report](docs/MCP_SERVER_IMPLEMENTATION_REPORT.md) | -| MCP-003 | P1 | blocked | WF-22 | Add validated `pack_validate`, `worktree_create`, single-run `launch`, workflow validate/launch/status/resume, `progress`, `ack`, and `steer` tools through existing services. Require idempotency and durable evidence mapping; steering remains `pending` without correlated acknowledgement. | [MCP pack Phase 3](prompt-packs/mcp-server-next/phase-3/README.md) | +| MCP-003 | P1 | ready | WF-22 | Add validated `pack_validate`, `worktree_create`, single-run `launch`, workflow validate/launch/status/resume, `progress`, `ack`, and `steer` tools through existing services. Require idempotency and durable evidence mapping; steering remains `pending` without correlated acknowledgement. | [MCP pack Phase 3](prompt-packs/mcp-server-next/phase-3/README.md) | | MCP-004 | P2 | deferred | MCP-003 | Add policy-gated interrupt/terminate and review/accept/reject tools, then run representative-host and security evaluation. Workflow approvals must reuse workflow review receipts. | [MCP decision: MCP-3 and MCP-4](docs/MCP_SERVER_DECISION.md#phased-implementation-plan) | | MCP-005 | P2 | decision | MCP-004 | Authorize or reject Streamable HTTP only after local stdio adoption and security evidence; require a separate authorization ADR before implementation. | [MCP decision: MCP-5](docs/MCP_SERVER_DECISION.md#conditional-http-evolution) | @@ -97,6 +98,8 @@ The read-only local stdio MCP adapter is already implemented and remains valid. |---|---|---|---| | HIST-001 | 0.1.5 | Durable fsynced control records, best-effort tmux wakeups, visible same-window panes, usage accumulation, verifier timing, and immutable `eval collect`/`eval compare` landed. | [implementation completion plan](docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md), commit `6b61cbb` | | HIST-002 | 0.1.6 | Global editable installer now installs core Python dependencies and retains its pip-managed launcher. | [installation guide](docs/INSTALLATION.md), commit `306c6f5` | +| HIST-003 | 0.2.0 | Completed workflow approvals, result binding, aggregate receipts, templates, routing, provider evidence, and trial-evidence hardening; removed unused vendored MCP SDK source. | [0.2.0 historical gate](docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md) | +| HIST-004 | 0.2.1 | Corrected workflow child authority, active-capacity accounting, restart reconciliation, retry/dependency replay, immutable snapshot/journal locking, projection recovery, provider usage validation, and score-set receipt authority after an independent critical re-review of 0.2.0. | [0.2.1 critical review](docs/execution-evidence/FINAL_CRITICAL_REVIEW.md) | ## Reference map diff --git a/CHANGED_FILES.md b/CHANGED_FILES.md index 2b76fa0..3ee93ff 100644 --- a/CHANGED_FILES.md +++ b/CHANGED_FILES.md @@ -1,87 +1,70 @@ -# Changed-files delivery +# Changed files -Relative to the previous `agent-workflow-0.1.8-mcp-server-next-completed-source.tar.zst` delivery. +**Release:** 0.2.1 +**Baseline:** delivered 0.2.0 commit `4ece38c` +**Scope:** critical-review repair delta only -Changed or added files: 67 -Removed files: 0 +This ledger lists every tracked path changed by the 0.2.1 repair. Status codes use Git name-status semantics. -## Changed or added +- Modified: 55 +- Added: 0 +- Deleted: 0 +- Renamed: 0 -- `BACKLOG.md` -- `CHANGED_FILES.md` -- `CHANGELOG.md` -- `MANIFEST.sha256` -- `README.md` -- `docs/ARCHITECTURE.md` -- `docs/MCP_SERVER_DECISION.md` -- `docs/MCP_SERVER_IMPLEMENTATION_REPORT.md` -- `docs/WORKFLOW_FOUNDATIONS_PLAN.md` -- `prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md` -- `prompt-packs/mcp-server-next/MANIFEST.sha256` -- `prompt-packs/mcp-server-next/README.md` -- `prompt-packs/mcp-server-next/phase-0/task-manifest.yaml` -- `prompt-packs/mcp-server-next/phase-1/task-manifest.yaml` -- `prompt-packs/mcp-server-next/phase-2/task-manifest.yaml` -- `prompt-packs/mcp-server-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md` -- `prompt-packs/mcp-server-next/phase-3/README.md` -- `prompt-packs/mcp-server-next/phase-3/task-manifest.yaml` -- `prompt-packs/mcp-server-next/phase-3/tickets/P3-00-workflow-baseline.md` -- `prompt-packs/mcp-server-next/phase-3/tickets/P3-01-workflow-aware-tools.md` -- `prompt-packs/mcp-server-next/phase-3/tickets/P3-02-independent-review.md` -- `prompt-packs/workflow-foundations-next/CHATGPT_HANDOFF_PROMPT.md` -- `prompt-packs/workflow-foundations-next/DELEGATION_RUNBOOK.md` -- `prompt-packs/workflow-foundations-next/EXECUTION_PROTOCOL.md` -- `prompt-packs/workflow-foundations-next/MANIFEST.sha256` -- `prompt-packs/workflow-foundations-next/README.md` -- `prompt-packs/workflow-foundations-next/contracts/workflow-node-result.schema.json` -- `prompt-packs/workflow-foundations-next/pack.yaml` -- `prompt-packs/workflow-foundations-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md` -- `prompt-packs/workflow-foundations-next/phase-0/README.md` -- `prompt-packs/workflow-foundations-next/phase-0/task-manifest.yaml` -- `prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md` -- `prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md` -- `prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md` -- `prompt-packs/workflow-foundations-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md` -- `prompt-packs/workflow-foundations-next/phase-1/README.md` -- `prompt-packs/workflow-foundations-next/phase-1/task-manifest.yaml` -- `prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md` -- `prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md` -- `prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md` -- `prompt-packs/workflow-foundations-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md` -- `prompt-packs/workflow-foundations-next/phase-2/README.md` -- `prompt-packs/workflow-foundations-next/phase-2/task-manifest.yaml` -- `prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md` -- `prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md` -- `prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md` -- `prompt-packs/workflow-foundations-next/references/README.md` -- `prompt-packs/workflow-foundations-next/scripts/archive-prompt-pack.sh` -- `prompt-packs/workflow-foundations-next/scripts/check-delegation.sh` -- `prompt-packs/workflow-foundations-next/scripts/create-ticket-worktree.sh` -- `prompt-packs/workflow-foundations-next/scripts/foreground-delegation.sh` -- `prompt-packs/workflow-foundations-next/scripts/launch-delegation.sh` -- `prompt-packs/workflow-foundations-next/scripts/restart-delegation.sh` -- `prompt-packs/workflow-foundations-next/scripts/stop-delegation.sh` -- `prompt-packs/workflow-foundations-next/scripts/validate-prompt-pack.sh` -- `prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md` -- `prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md` -- `prompt-packs/workflow-foundations-next/templates/source-baseline.example.json` -- `schemas/task-manifest.schema.json` -- `schemas/task-result-collection.schema.json` -- `schemas/task-result.schema.json` -- `src/agent_workflow/manifests.py` -- `src/agent_workflow/receipts.py` -- `src/agent_workflow/runner.py` -- `src/agent_workflow/sessions.py` -- `tests/test_manifest_validation.py` -- `tests/test_runner_generation.py` - -## Removed - -None. - -## Verification scope - -- Revalidated the implemented dependency-DAG and structured-result foundations. -- Revalidated both prompt packs after explicit cross-phase dependency updates. -- Reordered remaining MCP mutation work behind completion of `WF-22`. -- Regenerated prompt-pack and repository release manifests. +| Path | Status | +|---|---| +| `BACKLOG.md` | M | +| `CHANGED_FILES.md` | M | +| `CHANGELOG.md` | M | +| `CLEANUP_AND_REMOVAL_AUDIT.md` | M | +| `FEATURE_TEST_LEDGER.md` | M | +| `MANIFEST.sha256` | M | +| `SESSION_RESTORE.md` | M | +| `VERSION` | M | +| `agent-workflow.yaml` | M | +| `docs/ARCHITECTURE.md` | M | +| `docs/MCP_SERVER_DECISION.md` | M | +| `docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md` | M | +| `docs/MCP_SERVER_IMPLEMENTATION_REPORT.md` | M | +| `docs/PROVIDER_EVIDENCE_RESEARCH.md` | M | +| `docs/WORKFLOW_FOUNDATIONS_PLAN.md` | M | +| `docs/diagrams/REPOSITORY_CHART_PACK.md` | M | +| `docs/diagrams/provider-evidence.mmd` | M | +| `docs/diagrams/workflow-scheduler.mmd` | M | +| `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md` | M | +| `docs/execution-evidence/PHASE_GATE_REPORT.md` | M | +| `docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md` | M | +| `docs/man/agent-workflow-mcp.1` | M | +| `docs/man/agent-workflow-workflow.1` | M | +| `docs/man/agent-workflow.1` | M | +| `prompt-packs/mcp-server-next/MANIFEST.sha256` | M | +| `prompt-packs/mcp-server-next/README.md` | M | +| `prompt-packs/mcp-server-next/pack.yaml` | M | +| `pyproject.toml` | M | +| `schemas/workflow-snapshot.schema.json` | M | +| `src/agent_workflow/__init__.py` | M | +| `src/agent_workflow/approval.py` | M | +| `src/agent_workflow/bindings.py` | M | +| `src/agent_workflow/cli.py` | M | +| `src/agent_workflow/doctor.py` | M | +| `src/agent_workflow/eval/scoring.py` | M | +| `src/agent_workflow/eval/trials.py` | M | +| `src/agent_workflow/lifecycle.py` | M | +| `src/agent_workflow/provider_evidence.py` | M | +| `src/agent_workflow/receipts.py` | M | +| `src/agent_workflow/runner.py` | M | +| `src/agent_workflow/scheduler.py` | M | +| `src/agent_workflow/sessions.py` | M | +| `src/agent_workflow/util.py` | M | +| `src/agent_workflow/workflow.py` | M | +| `src/agent_workflow/workflow_receipt.py` | M | +| `src/agent_workflow/workflow_service.py` | M | +| `tests/test_approval.py` | M | +| `tests/test_eval_reporting.py` | M | +| `tests/test_eval_trials.py` | M | +| `tests/test_lifecycle.py` | M | +| `tests/test_provider_evidence.py` | M | +| `tests/test_receipts.py` | M | +| `tests/test_session_launch.py` | M | +| `tests/test_workflow.py` | M | +| `tests/test_workflow_receipt.py` | M | diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fcfc06..5758e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,28 +3,46 @@ ## Unreleased +No unreleased changes. + +## 0.2.1 — 2026-07-24 + +- Reconcile running workflow nodes from verified child provenance and sealed terminal evidence; count existing running nodes against parallelism and require a durable child footprint before recording `running`. +- Make recoverable retries replay-valid and reopen dependency-failed descendants when a prerequisite retry begins or succeeds. +- Store canonical workflow snapshots and aggregate receipts read-only in the atomic rename, reject writable/symlink substitutions, validate journals before append, lock journal reads, fsync directory entries, and refresh workflow projections after scheduling and status reads. +- Reject duplicate dependency/session identifiers in workflow snapshots. +- Validate trial score sets against content-addressed scorer receipts and the sealed final receipt instead of trusting a mutable verdict file. +- Harden provider evidence against symlinked or changing raw streams, empty/conflicting/non-finite terminal usage, nonmonotonic cumulative totals, incomplete cost metadata, and ambiguous duplicate deltas without provider event identity. +- Reject symlinked lifecycle receipt roots, fsync lifecycle directory entries, serialize final-run seal creation/verification, and read/hash final receipts and artifacts from stable non-symlink descriptors. +- Return final-receipt digests from the same lock-scoped descriptor used for verification; read and hash aggregate workflow receipts from one descriptor under the workflow lock. +- Install content-addressed scorer receipts read-only, reject symlink/writable substitutions, and hash the exact score-set bytes validated for lifecycle review. +- Read authority-bearing sealed JSON through beneath-root no-symlink descriptors and recheck receipt size/hash before lifecycle, approval, scheduling, binding, workflow-receipt, or trial decisions. +- Cover required and optional sealed trees in the read-only pass; reject symlink chmod targets and intermediate symlink components during seal creation and verification. +- Write parent and child workflow input snapshots, native-job source snapshots, and job-binding receipts read-only before their atomic rename and before executor launch. +- Add focused regression coverage and supersede the original 0.2.0 critical-review conclusion. + +## 0.2.0 — 2026-07-24 + +- Add restart-safe workflow graphs with receipt-backed approval gates, bounded + JSON Pointer result binding, aggregate workflow receipts, deterministic graph + templates, and explainable routing advice. +- Add bounded sealed provider stream evidence with explicit delta, cumulative, + and terminal usage semantics; preserve cached, cache-write, reasoning, billed, + estimated, currency, retry, error, and steering evidence without double counting. +- Extend immutable trial evidence and cohort comparison validity checks. +- Harden final and lifecycle receipt verification against mutable status + projections, symlinks, writable receipts, substitutions, omissions, and stale + workflow snapshots. +- Complete the workflow integration gate, update CLI/help/skills/man pages, add + repository and MCP architecture chart packs, and publish the MCP mutation + implementation proposal and threat model. +- Remove the unused vendored MCP Python SDK source snapshot; retain only the + pinned optional dependency and public-API integration. - Track bounded context and assignment history for interactive agents with - explicit completion, same-worktree ranking, stale-idle policy, and - exact-lineage-only automatic reuse. -- Add pending reassignment and correlated acknowledgement states without - inferring task completion or delivery from terminal text. -- Enforce globally unique active agent names across pane and detached modes. -- Configure interactive capacity as width x vertical slots; default to two - right-side columns with three agents each, creating columns horizontally - before balancing vertical splits. -- Prompt at the interactive pane cap to close explicitly idle panes, launch - detached/non-interactive, or cancel; non-TTY callers fail closed unless an - action is supplied. -- Apply the workflow-foundations sequencing addendum: cross-phase dependency - DAGs, schema-validated task results, and MCP mutations blocked behind WF-22. - -- Correct the canonical backlog so every workflow prompt-pack ticket is a stable task with explicit dependencies and direct ticket links. -- Sequence all remaining mutating MCP work after the complete workflow foundation, while preserving the completed read-only stdio adapter. -- Extend the MCP continuation prompt pack with a workflow-aware safe-mutation phase that reuses authoritative CLI/domain services. - -- Validate prompt-pack dependencies as a cross-phase directed acyclic graph. -- Add optional schema-validated structured task results with bounded handoff collection and sealed receipts. -- Add the scoped workflow-foundations plan, backlog, and remaining-work prompt pack. + explicit completion, same-worktree ranking, stale-idle policy, exact-lineage + automatic reuse, globally unique active names, and configurable pane capacity. +- Validate prompt-pack dependencies as a cross-phase DAG and collect structured, + schema-validated task results into sealed run evidence. ## 0.1.8 diff --git a/CLEANUP_AND_REMOVAL_AUDIT.md b/CLEANUP_AND_REMOVAL_AUDIT.md index ba9f62b..20ae65a 100644 --- a/CLEANUP_AND_REMOVAL_AUDIT.md +++ b/CLEANUP_AND_REMOVAL_AUDIT.md @@ -1,30 +1,60 @@ # Cleanup and removal audit -This file records repository paths that are safe to remove because they are generated output, stale one-off execution evidence, or duplicated historical delivery material. Paths are relative to the repository root. +**Release:** 0.2.1 +**Updated:** 2026-07-24 -## Removed across the two cleanup passes +This audit distinguishes material removed in this release from optional historical material and generated files that must not be shipped. Paths are repository-relative. -- `VALIDATION.md` — stale release-validation artifact for version 0.1.4; contradicted the current 0.1.6 source. -- `IMPLEMENTATION.patch` — one-off delivery patch generated from a 0.1.3 baseline; not part of the maintained source tree. -- `IMPLEMENTATION_REPORT.md` — one-off implementation report coupled to the obsolete patch and 0.1.3 baseline. -- `src/agent_workflow/__pycache__/` -- `src/agent_workflow/eval/__pycache__/` -- `src/agent_workflow/integrations/__pycache__/` -- `tests/__pycache__/` -- A project-specific runtime adapter and its dedicated test module were removed because they do not belong in the general-purpose workflow package. Exact deletion paths are recorded in `REMOVED_PATHS.txt`. +## Removed in the 0.2.0 workflow-completion pass -The `__pycache__` directories are interpreter-generated bytecode and must never be shipped in a source archive. The project-specific integration was removed together with its schema, runner, session, receipt, manifest, and documentation references. +| Path | Disposition | Reason | +|---|---|---| +| `src/agent_workflow/mcp/sdk/` | removed, 141 tracked files | Unused vendored copy of the official MCP Python SDK. It was not imported by runtime code, was excluded from package discovery, duplicated upstream maintenance/security work, and added about 1.8 MB of drift. Runtime and tests use the pinned optional dependency through public APIs. | +| `src/agent_workflow/mcp/SDK_SNAPSHOT.md` | replaced by `SDK_DEPENDENCY.md` | The old name implied a maintained vendored snapshot. The replacement records the exact external dependency/tag/commit without carrying third-party source. | -## Safe optional removals after maintainers no longer need execution provenance -- `docs/execution-evidence/` — ticket and phase-gate evidence from the 2026-07-23 skills/MCP prompt-pack execution. Keep while reviewing that delivery; remove after the result is accepted and durable history is recorded elsewhere. -- `prompt-packs/agent-workflow-skills-and-mcp/` — completed input prompt pack. Keep only when reproducing or auditing that execution. -- `prompt-packs/orchestrator-messaging-evals/` — historical implementation prompt pack. It remains useful for provenance, but is not runtime-required. +## Reviewed in the 0.2.1 critical repair -## Keep +- Consolidated workflow journal parsing on the descriptor-safe locked reader instead of maintaining a second receipt-specific parser. +- Retained the workflow lock as a single small durable coordination file; rejected symlink/non-regular substitutions rather than introducing a daemon, database, or broker. +- Removed generated caches and build output before packaging. +- Re-reviewed historical prompt packs and execution evidence. They remain explicit provenance, not runtime dependencies or active task trackers. +- Found no additional vendored dependency, duplicate scheduler, compatibility layer, project-specific adapter, or unused runtime subsystem that could be removed safely. -- `docs/PHASE_3_BLOCKED_GATE_REPORT.md` and `tests/fixtures/regression-evals/priority-picker/README.md` — these document an active blocked backlog item and its stop condition. -- `examples/three-phase-pack/` — validation fixture and user-facing structural example. -- `scripts/` — compatibility entry points intentionally retained for older prompt packs. -- `templates/` and `src/agent_workflow/assets/` — source and packaged copies are both required; release auditing verifies that portable assets remain synchronized. -- `CHANGELOG.md` historical release entries — older versions are intentional history, not stale current-state claims. +## Generated material removed before packaging + +These paths are safe to delete at any time and are excluded from release archives: + +- `.pytest_cache/` +- every `__pycache__/` directory and `*.pyc` file, including those under source, tests, and deterministic fixtures; +- Python build output such as `build/`, `dist/`, and `*.egg-info/` when generated locally; +- editor/OS temporary files and test scratch directories. + +## Historical removals retained in Git history + +Earlier cleanup passes removed one-off delivery artifacts (`VALIDATION.md`, `IMPLEMENTATION.patch`, and the root `IMPLEMENTATION_REPORT.md`) and an unrelated project-specific adapter/test. They remain absent. They are not repeated as current 0.2.0 source changes. + +## Optional archival candidates, not removed + +These are not runtime dependencies, but they retain useful provenance or reusable examples. Remove them only under an explicit repository-history policy: + +| Path | Why it can be archived | Why it remains now | +|---|---|---| +| `docs/execution-evidence/` | Completed ticket/gate reports are not imported at runtime. | Provides reviewable release provenance and records the original WF-10 rejection and correction boundary. | +| `prompt-packs/agent-workflow-skills-and-mcp/` | Completed historical input pack. | Useful for reproducing the skill/MCP foundation. | +| `prompt-packs/orchestrator-messaging-evals/` | Completed historical input pack. | Preserves the durable messaging/evaluation design lineage. | +| `prompt-packs/workflow-foundations-next/` | Superseded as an execution queue by the completed implementation. | Authoritative ticket contracts and prior independent-review history remain valuable. | +| `prompt-packs/chatgpt-workflow-completion-next/` | Completed input pack. | Retained as the exact implementation instruction set executed for 0.2.0. | +| `examples/three-phase-pack/` | Not runtime code. | Maintained user-facing example and validation fixture. | + +## Reviewed and retained intentionally + +- `scripts/`: compatibility entry points and release/prompt-pack tooling remain exercised. +- `templates/` and `src/agent_workflow/assets/`: both source and packaged copies are required; release auditing checks synchronization. +- `tests/fixtures/` and `evals/fixtures/`: deterministic regression inputs, not user/runtime debris. +- historical `CHANGELOG.md` and `BACKLOG.md` entries: release history, not stale current-state documentation. +- the read-only MCP implementation under `src/agent_workflow/mcp/`: active code; only the unused vendored SDK subtree was removed. + +## Simplification conclusion + +No second scheduler, executor launch path, database, broker, daemon, HTTP service, online-learning layer, arbitrary terminal steering mechanism, or compatibility shim was introduced. The largest confirmed drift source was the vendored SDK and it has been eliminated. Remaining optional historical packs/evidence are explicit provenance rather than hidden runtime complexity. diff --git a/FEATURE_TEST_LEDGER.md b/FEATURE_TEST_LEDGER.md index 00da894..833713e 100644 --- a/FEATURE_TEST_LEDGER.md +++ b/FEATURE_TEST_LEDGER.md @@ -1,46 +1,37 @@ -# Agent-workflow feature test ledger +# Feature and test ledger -This is the running verification record for the P0 workflow-foundation -implementation round. Results are based on executable tests, agent-workflow -receipts, focused probes, and live observations. `partial` means the durable -core exists but an important operational boundary remains; `failed` means a -reproducible correctness defect; `useful` and `pointless` are practical value -assessments, not quality scores. +**Release:** 0.2.1 +**Updated:** 2026-07-24 +**Purpose:** Current evidence map for behavior that materially affects orchestration authority, restart safety, provider accounting, and the planned MCP mutation surface. -| Feature | Result | Usefulness | Evidence / current limitation | -|---|---|---|---| -| Codebase-memory MCP indexing | pass | useful | Full persistent index rebuilt before this round: 5,108 nodes, 11,310 edges; `.codebase-memory/graph.db.zst` written. | -| Prompt-pack validation and dependency DAG checks | pass | useful | Pack validation and WF-001 tests reject malformed, unknown, self, and cyclic dependencies. | -| Structured completion contracts and handoff collection | pass | useful | WF-002 completion JSON/schema collection and sealed artifact tests pass. | -| Agent class/model/no-go policy routing | pass | useful | Config and session-launch tests cover executor-specific models, classes, permissions, and no-go authorization. | -| Unique agent names across active interactive/detached runs | pass | useful | Session-launch naming tests pass; active-name collisions are rejected or receive another configured name. | -| Detached noninteractive tmux sessions | pass | useful | WF-00 and WF-01 runs use dedicated sessions with durable state and no visible pane consumption. | -| Interactive pane layout/capacity and mouse behavior | pass (automated) | useful | tmux tests cover horizontal columns, vertical stacking, capacity preflight, dead-pane handling, and mouse configuration; live pane behavior remains observational. | -| Durable steer/progress/ack records | partial | useful | JSONL append/replay, fsync, correlation, and validation pass; one-shot Codex/Claude processes do not yet consume late steering semantically. Tracked as BKL-002. | -| Child progress emission from delegated runs | partial | useful | Child attempts to emit progress, but executor sandbox access to the external run-state messages file can fail read-only; delivery is not yet adapter-backed. | -| Lifecycle review/accept/reject disposition | partial | useful | Lifecycle code requires a deterministic score set even for ordinary non-evaluation implementation runs; WF-00 disposition could not be recorded without fabricating a score. | -| WF-00 workflow schemas, event journal, replay, and corruption rejection | pass | useful | Mini implementation plus independent Luna review accepted at `64ee486`; focused and full-suite gates passed. | -| WF-01 dependency scheduler | pass | useful | Luna correction plus independent Mini re-review accepted at `82d56d3`; race, crash-window, dependent advancement, provenance, manifest, and full-suite checks pass (171 passed, 1 skipped). | -| Release manifest portability across linked worktrees | pass for WF-00 and WF-01 correction | useful | Corrected auditor excludes `.git` directory and worktree control file; Luna regenerated and validated the manifest. | -| Optional read-only MCP server/resources | pass | useful | Existing MCP protocol/service tests pass; bounded resources and `pack_validate` are available. | -| MCP mutation tools | deferred | useful | Correctly blocked until WF-22; must call shared services and report pending steering honestly. | -| WF-02 restart recovery and workflow CLI | pass | useful | Mini correction `wf02-mini-r3-20260724` plus independent Luna review `wf02-review-luna-r3-20260725` accepted at `ea77a74`; direct JSON/human CLI probes, bounded filesystem errors, 178 passed/1 skipped, and release audit passed. | -| Executor command safety during review | partial | useful but frictional | A review probe using `mktemp` plus narrowly scoped `rm -rf "$tmp"` was rejected by the executor safety filter before execution. The guard protects against destructive commands but does not distinguish a validated temporary path; the reviewer completed using `/dev/null` and non-destructive cleanup. | -| Delegated linked-worktree commit/finalization | partial | useful but incomplete | WF-10 Mini implementation reached green focused/full/audit gates, but the linked worktree could not write its Git index lock, so the delegated commit and completion sidecar could not be finalized. The run was terminated with sealed evidence preserved; acceptance requires coordinator-side recovery plus independent review. | -| WF-10 receipt-backed approval gates | rejected | useful | Independent Luna review found a correctness/security gap: approval trusts mutable `status.json` receipt-path state and does not prove the referenced receipt is the append-only artifact for this run, allowing a forged/copied same-session receipt with matching fields. Full review gates passed (181 passed/1 skipped; release audit valid), but acceptance is blocked pending correction. | -| Claude launch interactivity default | pass | useful | Session policy tests pass: Claude defaults to interactive even for exploratory/review classes; explicit `interactive=False` remains non-interactive. Focused launch/config/CLI/executor suite: 36 passed. | -| Global install/doctor/build path | pending this round | useful | Must be rerun after cumulative P0 integration; no claim made yet. | +This ledger is a release aid, not a substitute for immutable run/workflow receipts. A feature is marked `pass` only when its contract has focused executable coverage and the related documentation matches the implementation. -## Practical assessment +| Feature or boundary | Status | Evidence and protected failure | +|---|---|---| +| Durable message journal and tmux wake hint | pass | `tests/test_messages.py` and `tests/test_tmux.py` prove replay-first authority, fsynced append behavior, bounded waits, and fallback when tmux wakeups are lost or unavailable. | +| Workflow contract and replay | pass | `tests/test_workflow.py` validates schemas, contiguous event sequencing, legal transitions, snapshot identity, descriptor-safe immutable reads, lock/journal symlink rejection, restart reconstruction, active-capacity accounting, authoritative child reconciliation, retry lineage, projection recovery, and dependency-failure reopening. | +| Receipt-backed approval gates | pass | `tests/test_approval.py` reconstructs the canonical append-only lifecycle chain, ignores mutable status projections, rejects symlinked receipt roots, lock files, non-regular or writable receipts, and fails closed on copied, omitted, duplicated, or tampered evidence. | +| Bounded result binding | pass | `tests/test_bindings.py` validates the supported RFC 6901 subset, ancestry, result/collection digests, per-value and aggregate size limits, optional/missing behavior, immutable parent snapshots, read-only child launch copies, and byte-stable replay. | +| Aggregate workflow receipts | pass | `tests/test_workflow_receipt.py` binds the normalized snapshot, durable event stream, exact node terminal states, retry attempts, child seals, bindings, and canonical approval evidence. Substitution, omission, duplication, partial state, post-seal approval tampering, and receipt/artifact symlink redirection fail verification. | +| Authorized workflow templates | pass | `tests/test_workflow_templates.py` covers only pipeline, bounded parallel-review/fan-in, and implementation-independent-review expansion and checks deterministic output. | +| Deterministic routing advice | pass | `tests/test_routing.py` verifies stable explanation codes, recommendation/enforcement separation, disagreement reporting, and fail-closed no-go policy. Launch still passes through the canonical session/config policy. | +| Provider stream evidence | pass | `tests/test_provider_evidence.py` covers bounded stable raw-stream capture, symlink/change rejection, event digests/sequences, identified replay idempotency with conflicting-ID rejection, ambiguous unidentified deltas, explicit delta/cumulative/terminal modes, monotonic totals, finite nonnegative usage, cached/cache-write/reasoning fields, terminal conflict rejection, complete currency/catalog metadata, and provider-billed versus catalog-estimated cost separation. | +| Trial collection and comparison | pass | `tests/test_eval_trials.py`, `tests/test_eval_compare.py`, and evaluation command tests reject incomplete/unsealed evidence and forged mutable score sets, validate regular read-only content-addressed scorer receipts against the exact verified final-seal digest, preserve nulls, bind source digests, and exclude currency/catalog mismatches from cost comparisons. | +| Sealed artifact path integrity | pass | `tests/test_receipts.py` covers stable final-receipt reads, exact artifact digests, optional-tree immutability, descriptor-based chmod, final/intermediate symlink rejection, and outside-target protection. | +| Retry accounting | pass | `tests/test_metrics.py` proves retry count is derived from sealed provenance or workflow attempt lineage rather than mutable session status. | +| Read-only MCP adapter | pass | `tests/test_mcp_services.py` and `tests/test_mcp_server.py` cover bounded resource access, configured-root containment, traversal/symlink rejection, redaction, protocol initialization, and the existing read-only/validation capability surface. | +| MCP mutation tools | not implemented | `MCP-003` is ready now that `WF-22` is complete. The proposal and threat model require reuse of canonical services, idempotency, durable result identifiers, and no raw shell/tmux/path authority. | +| Real-executor benchmark cohort | not run | `BKL-004` remains a controlled external execution task. No provider/model winner or production routing recommendation is claimed from synthetic fixtures. | +| Fresh independent external-agent review | unavailable in this environment | Historical independent reviews accepted WF-00 through WF-02 and rejected the original WF-10 status-path trust flaw. The corrected cumulative tree has focused tamper tests and a local critical review; no new Codex/Claude executable was available, so no external receipt is fabricated. | -The most useful features so far are isolated durable worktrees, explicit -executor/model policy, completion contracts, independent review, and the -codebase-memory graph. The least useful behavior is terminal capture as a -control mechanism: it is useful for observation, but pointless as proof of -semantic delivery. The current one-shot executor path is also insufficient -for interactive reuse or late steering until BKL-002 supplies a real adapter. +## Release-gate commands -Update this ledger whenever a feature is exercised, repaired, rejected, or -found to be operationally misleading. Do not mark a feature `pass` from an -exit code alone; retain the command, receipt, or reproducible probe that -supports the result. +The final command results for this delivery are recorded in `docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md` and `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md`. The release manifest must be regenerated after every source, documentation, schema, skill, prompt-pack, or test change. + +## Interpretation rules + +- Terminal capture is observational and never proves lifecycle state, usage, approval, or message delivery. +- `status.json` is a projection; canonical append-only records and sealed artifacts are authoritative. +- Unknown tokens, cost, currency, retry details, or durations remain `null`; they are not inferred from prose. +- A best-effort tmux wakeup may be lost, duplicated, or unavailable without losing a durable message. +- MCP is an adapter over repository services. It must not become a parallel workflow engine or expose arbitrary shell, filesystem, environment, or tmux control. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index c813f95..0a107a2 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -1,45 +1,70 @@ 7a97c19d87b6c07e6a28c16e18b7a1816fba6a2da530f8c488dba3f8e2f65b3a .cbmignore 9d4865b65c62212d1e2213e6d5305458e6442d23338bb484ef05417e748cc12a .gitignore -1c4b1c24da883075df621203f6f39d225d9fba07e56e01a29216ac0b34b6037b BACKLOG.md -dcda53b511c02c0bb95134fbd1542423397333e248059f94a107fd451d0f26c0 CHANGED_FILES.md -fbb4a0e05c07eb265c0d40a5d3f96c1cf9c8309a80755029e0f24b6092fb0ee4 CHANGELOG.md -fd8cd55f06c8443e8d7d0d333d91af947979c8edae149dc088c3937c3232b1be CLEANUP_AND_REMOVAL_AUDIT.md +1156b0d190d727ad7b4a25f7cec456c8a08bbe3452789f631ac5caaf08bc03e3 BACKLOG.md +c951d5b90c4626e5aaffc1791a42ed1dc75799206075dfadf3b0421f2c96159d CHANGED_FILES.md +b6f26db97ae0dd00a5d47636cda3c7e2a2ce6e9dcaaf729f7d607cc3c6d23dc9 CHANGELOG.md +2ab99b71cb8dcea158e8e2678be31f8ab919d12e1ea97468ee0e9a870d1ff907 CLEANUP_AND_REMOVAL_AUDIT.md a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -07536e948a06a629d5bd02fe2d78909eb0bf5edde7d1782403eaf30fdb29a4ee FEATURE_TEST_LEDGER.md +893ac876ece3a0617d24e43085292c1f18d5ce9ce50b0aa3b27795039627126e FEATURE_TEST_LEDGER.md 60cab04a3946f6ea21d245cb871a5e86333c85dfd90dc8943ab8adb926e5b0a7 Makefile -2e465b4f6913120cfa3239ec13d3ba48a0c3b027c9e8fbb13317f08bef4a469e README.md -fdc6e90c7327010ccc4a93fad0fe8b5ce2464b306de317b198620cbddc5a2086 REMOVED_PATHS.txt +975c05a0d5daee26106d2ddbce2b130d2997f1d27bcb66f9b95ebcd102ab2350 README.md +76f912155dbfee01d0275a42a0ee636b2aa8f5bd87bb343e98db31082e706d53 REMOVED_PATHS.txt 194ea1d829a11a08aa356befcc72607e37d948e5b16ab9bc0b08788547b22595 SECURITY.md -d8862c8f51f0edb80194f46e97952b731f0599876b15e8cb3b8eaad5c7dec687 SESSION_RESTORE.md -0a6c8dc4148eed841498d2c2be544947a62f8f50c381b2e64bcd7ce40461cf34 VERSION -0a5576f093aebffa5d31e244a6790aa15978de78c5e55fbae250167be240ff1d agent-workflow.yaml +0aa6f5de99829d5b30d9e15a1661961c9d70c9e81baedd896ffc3bf5d57cd60a SESSION_RESTORE.md +71015c979ccb0fc8a0be7ca0ae83046ab045cdc2c8faa09fb2f0f7e440f9b4a6 VERSION +8346c780f5e7fe16eae74db2661dd0f817be70fc89bd5e6fccc4439736543f5b agent-workflow.yaml de632c1a29079d61fafa72732c60b34414d92d2aab4004948be39291e363ffbf bin/agent-workflow a1a9011fe663f0e7f126cb3e1541ead76bebe11cb832a1edd81a8f9aeed3a3a3 config/agent-workflow.example.toml -480c77f05f0cf30e942268fe8eafeab19780dcf8aa4889c1341814df06461f0a docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md -11919c9936ba1ae4fa883dc1a5204fb799122ec752d1f0d0ce8564ece6d9a265 docs/ARCHITECTURE.md -4358290c5a0bc183b7334cef4dbd4842c0e7c41556fc054eb2e75d04d744d3d7 docs/COMMAND_REFERENCE.md +c3f1b77c023226bae4e9e3698475c8cf61fb6c5cc5d161118dbc6a2baacbd612 docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md +afb8f6af1237f2fa5d833f0f3636b85169a671e5792207eb9a6e61d9c80d5ee3 docs/ARCHITECTURE.md +3390b6f3a1e3857b7f3516c09b76f615cdbb84b2cc5e3ce4ba9f1054f6ceef7c docs/COMMAND_REFERENCE.md 7bd3d0bf460dd4313298f26f92814b6117b223527221efdc2ca5b63b81eaf5ae docs/DELEGATION_LIFECYCLE.md -3f288f52f1cba8cafbc4c9fdb398b52fcc14fcdf2bb7452a05cd7ae4e2d32cce docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md +840b4553d6d166a9063e3644e5466739749b8312ac5c896b0d2126346eddd29a docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md de70c2fa9c3e57791005ea29e411703d95f8ab27e1d7c8a5227ac2c3b9e33f20 docs/Durable_Orchestration_Delivery_Benchmarks.md e08fff30d5fee8cae8208b5b88c0ccf3825d117df5a6ead2383fb463fd5056d2 docs/GLOBAL_AGENT_ROUTING.md a1698eee63c79213c836d2d89a55be7dce801c0403e8b4ef339b23c6b32dd2de docs/INSTALLATION.md -5f7d6a6d765077a37ff1596173d94b97e7bd555d0d074afd8123070e810efcbb docs/MCP_SERVER_DECISION.md -a72164b2d05808add9f1888a1eb38bc081f31be308053bffef60adbd1f7234d8 docs/MCP_SERVER_IMPLEMENTATION_REPORT.md +6a87ccf0ccc9f4c6ae88ee8044c0631f379df2463ea7f2c1d07c0c9703d4e635 docs/MCP_IMPLEMENTATION_CHECKLIST.md +59a8fd116cba9f5053836d9b4db595ba74bf1d374d4736fbf3a85b67468dec3f docs/MCP_SERVER_DECISION.md +c84998f1d6a1dedf20538b3980b13c30e117a6b039305d23ff22158b9b33acda docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md +5bbbd48ccaa303b94546a479d0fd4010c159f1a45745a3cde4311720d92fb262 docs/MCP_SERVER_IMPLEMENTATION_REPORT.md +dc186be21e1dd7275df7b88a312b0c2efdb0e6e328319c60c9c9683b836f0b75 docs/MCP_THREAT_MODEL.md +558f7954a686fcfd9d3ebaaaed374a67d9d113765e9d59602a79d5b25d2577e2 docs/MCP_TOOL_AND_RESOURCE_CATALOG.md 659af09c0923a77bd5b4e5418c416706959a821446a3368a569b739205dc36ef docs/MESSAGING_PRIOR_ART_REPORT.md 6594aa6a195f6517f5434686273840a8d4a0b1c371fb9ecdccb6126783af1f17 docs/MIGRATING_EXISTING_ASSETS.md 5ce0539252fdffb7aaa4c2aa3b035d7888333c691f7fa51473a2ebf34bcc9e7a docs/MODEL_TIERS.md 6d7912023dbc0148dd4735afe6f590fc5a5ab5291b1f9655dc386fe359d1a7f1 docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md 4e9c8d903abc99c531c8fc0f15cb723534dbb373615c351fa43ffcfd43c1816c docs/PHASE_3_BLOCKED_GATE_REPORT.md c18a375de14669c1589849ee794836340ed6bbe16556eaa92fd545e8ce60ae18 docs/PROMPT_PACK_STANDARD.md +45ce780c184cc6ea872ac5ee0c3c2d478d3c29d9611344ebe2bba01b0f657429 docs/PROVIDER_EVIDENCE_RESEARCH.md +82f37eadbf373864d7e9f49a63d7143b80f3fa0dfe767d62fe5c81cd70462ad8 docs/SECURITY_TRUST_BOUNDARIES.md eefdc7d02b26c9ffc34ec2e1e948a7533f237deca50275f3fc0debcc82e22b53 docs/STALL_RECOVERY.md a4cf89724dd97f387f755b9469b51f6893738d27daa50ddb11094b1c5525bdd8 docs/TEST_POLICY.md -2b8ac8a5927bf61667aac5a8ffee201f4c1885ff28917e657bf052ecd819680d docs/WORKFLOW_FOUNDATIONS_PLAN.md +8f4ce5089a93fa09197344808f94cdc4b7076d3c5a314ac157c80971e347480f docs/WORKFLOW_FOUNDATIONS_PLAN.md f045545a381be5fb8c9e86449f322fdddf47f5fbf241fbede156110ab60e9096 docs/adr/0001-inspect-evaluation-topology.md +2e6bfc5b2ca69f6e274706a36b922f54d62306df43e9ab5772e953c58b495f97 docs/diagrams/REPOSITORY_CHART_PACK.md +8d513f339b56f329ba8893b8aee847d7a3e665ece1784d8296ebe1c3816700d7 docs/diagrams/authority-hierarchy.mmd +116c4419252f15fab3d38eab07e42a90d275f9d2580fc13869e9246911c57360 docs/diagrams/evidence-erd.mmd +0d331614deafdb7931211d6334f2ac2f05a5d10459391420edc80dd4d61b1d2e docs/diagrams/mcp-target-architecture.mmd +0cfea84a9a87a03b85ed59b9ff380dba90b7e2a6dfdbbeda065f4ce47d231f76 docs/diagrams/provider-evidence.mmd +97997a5fcc25b181ca413f346bf6cf50f1556a05ffb527249d9ca7c6795501c0 docs/diagrams/workflow-scheduler.mmd +2db45629ac8f0980cea4de70a76f68baa9b36e724efda18ca051e9cc2d5098a6 docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md +33ac85d90c1289893d93dfd083e918bf1abb623828fa551d2be616050c3dade2 docs/execution-evidence/BKL-003-TICKET-COMPLETION.md +e155cd9421693831291f0a51de6cceb427b9e7f0d33d735d45b14ec3ea8173bc docs/execution-evidence/FINAL_CRITICAL_REVIEW.md 9fc3010cbf4e3d83261278ad356163720e5a5cf342dc2db3ebe7b5b9363826b8 docs/execution-evidence/P0-00-TICKET_COMPLETION.md 5ea9903db4dbdbcbc2e19f7f546ed99b0b33f06d1e474b49bddc4d6bc0b1bccb docs/execution-evidence/P0-01-TICKET_COMPLETION.md d5f189a7532b235d8ebcd09f4acfc371d489a2bec4ba17f3ad0fcca155d1ebe4 docs/execution-evidence/P1-00-TICKET_COMPLETION.md -239110eaa85c4dd0cf7e6212861f9480e604542b84b7b515ad5e490a4f6b0778 docs/execution-evidence/PHASE_GATE_REPORT.md +69c7e24d2a576a9ad0006e38933500e4af5ebce3632804d677231f4bdd259d2c docs/execution-evidence/PHASE_GATE_REPORT.md +7fa90ddec36704c43f2f90cf0c7b0d7ca2fc71393a869cf69684c240fb8b156a docs/execution-evidence/WF-10-TICKET-COMPLETION.md +67eb670a8318241dccce853841557eda0624d513d065dc13967ee9506398ccb7 docs/execution-evidence/WF-11-TICKET-COMPLETION.md +8195c3802da759b2d038579abf340c1394112ca4526cb8ccd5b3e2666296477b docs/execution-evidence/WF-12-TICKET-COMPLETION.md +a39c56695180f0c4f239ef25d3a8f01889791534fa3d0ea268588b4af544c6ea docs/execution-evidence/WF-20-TICKET-COMPLETION.md +6cbd0388f17ec3c627060a342d218d344d9e68dfcf6d943ad5a19a1d25364a31 docs/execution-evidence/WF-21-TICKET-COMPLETION.md +ca47971358348fa5e946b683ca81335f426a03c45f7e1988ad8d19de0e574df2 docs/execution-evidence/WF-22-TICKET-COMPLETION.md +9059a24b560fcd3da029128a8b2e25b1905cf479de5453b0794448d9bd604003 docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md +3b69902ef91783fd5c6035d25e9f28592b3af2d70136197419d821a4b72114b4 docs/man/agent-workflow-mcp.1 +68efa03f0976505f79698d78ecf7c08a47af7abd909ecde768b6e4959bdaf66a docs/man/agent-workflow-workflow.1 +86b5a84878df2e1c011477c2d4dea0de5a82a25ac301e8115add43796eda2bf7 docs/man/agent-workflow.1 8c39b1b3724a7b7184a3cc0f74a5d79f4b12d0e0a38de65049ef772e74cd5d6c evals/Dockerfile c2128d6c1e7c46f0c127a8b74eed590fa27dddf20a04c981d6dc0899ad8f1c54 evals/README.md 6c70613d77cdcce6dfd85e098c29f798c7c24b6050e636919ebb0c4a2c98ca60 evals/datasets/development.jsonl @@ -101,13 +126,54 @@ a0b6f85ecfc917ea634e2d31901765369c5656a8f588c54594ccc8f7ed340d4b prompt-packs/a ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/agent-workflow-skills-and-mcp/templates/PHASE_GATE_REPORT.md c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/agent-workflow-skills-and-mcp/templates/TICKET_COMPLETION.md 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/agent-workflow-skills-and-mcp/templates/source-baseline.example.json +7e0976f8f782192607ad0fb6c8b88306d7647228e787f87f7c814267cb4e892e prompt-packs/chatgpt-workflow-completion-next/CHATGPT_HANDOFF_PROMPT.md +5bb7334ba2b658d9810f1c0a8780c5eb51acec4597f85d3ddc73fe97935e7ea1 prompt-packs/chatgpt-workflow-completion-next/DELEGATION_RUNBOOK.md +adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 prompt-packs/chatgpt-workflow-completion-next/EXECUTION_PROTOCOL.md +7a4f042f4b9c6935c9b2cfee509f0783dc94f20fe7ad335ed0b5d2b4a5d5ec62 prompt-packs/chatgpt-workflow-completion-next/README.md +a34bd83458605c47cc4f74aa3c1261dc4f6f185647521e8916738d46508d1276 prompt-packs/chatgpt-workflow-completion-next/contracts/workflow-node-result.schema.json +a70069129389ff6c4f8affe3fb94d8b91e8b42b1a72a69dd6bfd45e392f979a5 prompt-packs/chatgpt-workflow-completion-next/pack.yaml +f7523df45911ef669bcc812d26f8cef88a1ca8f8aa2f4fe5ac05dbd5a2321247 prompt-packs/chatgpt-workflow-completion-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +c72159dcbc2f216655a37e25bc1c843219ce0d0d0376369b3902a96f60459e80 prompt-packs/chatgpt-workflow-completion-next/phase-0/README.md +da114439c42bc72666375c5f693ad3a975fe2deb931963c3d038de5117f63c2e prompt-packs/chatgpt-workflow-completion-next/phase-0/task-manifest.yaml +325f6722e52dda55f8b565640f2dfa71bb25c12a7a44a6c078a1db7ee62416de prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-00-contract-and-state.md +8d0d431572c396e5f580455c459367f84d1ba375da5f74a6230bea37edfe59c5 prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-01-scheduler-service.md +0ed1811b6960deca79bdf16ad6601ecf1f39d5a8d8841c2af001bba42015269d prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-02-restart-and-cli.md +be0c18b729ce86203318c5f0604f800f197026dc38d70336009a97fdbdfa6f05 prompt-packs/chatgpt-workflow-completion-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md +7ce12c4c9bb6ecd96c2cb3210063cf5f8a1fba4e7cbdbec061728e1ba58981f9 prompt-packs/chatgpt-workflow-completion-next/phase-1/README.md +1586d81810dca16935cf763d16f15a2f0e526674e60b5eae8a1beaf5d74a179e prompt-packs/chatgpt-workflow-completion-next/phase-1/task-manifest.yaml +b595b2a035988910d0e7315852d0f3d9b68b4039d8dd9e8a5a6726794faaf22e prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-10-approval-gates.md +0a6f2bcb59ef8b1abd2a038dbe7344ca78e5872c3b5269e4d09fcf330955e20b prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-11-result-binding.md +2ad9a00fb19ddd2533d6db54c97f1805fc3bf91ad1ec2571cebf17b78ff3839d prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-12-workflow-receipt.md +54511b2b64f1b90dca042b12bea9bed0080a6f89f8b910f2120c24079ae3f17c prompt-packs/chatgpt-workflow-completion-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md +e4b9938f2d00d52f56d288ac97751b7e4972053d36e9c54ad6133164c10d937a prompt-packs/chatgpt-workflow-completion-next/phase-2/README.md +b1a5a2c32148f7adb1e6b4df2753823ee7c087d4c8ed5b97dba0e7e31884f800 prompt-packs/chatgpt-workflow-completion-next/phase-2/task-manifest.yaml +e4a9948cda12a880e465ddc911f06583f96aaf5f520d8494ec539a9d17974d9b prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-20-templates.md +d4932ccc4b6c4734745451cd76c439b42e78cdd36d7289d020bea44f5e5ddeb3 prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-21-routing-advice.md +504c047b3cb460439975726ca8b891acdd0f5c6b379d196541c4cf5a6ae8064e prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-22-integration-review.md +0480ea0d3b5437b7a5b732786c8b4504ab79d761ed8168168d4c481c366e518e prompt-packs/chatgpt-workflow-completion-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md +7b4150861c904b1288bbb47a488050f9560300322f20627edf4776355f055222 prompt-packs/chatgpt-workflow-completion-next/phase-3/README.md +a31211d7aba32b9f13c4ae77b210b66d2678c52618dc5c90f2d7ef5270f41259 prompt-packs/chatgpt-workflow-completion-next/phase-3/task-manifest.yaml +1e092212995cce013d5737a3a3a9d1d14d46a8da24e8a0d6816dd603efc31ed6 prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003-RESEARCH.md +d10b15b3c1e73f58cecd40c5113f98987061b9efa058739d4c55f5a905ab71a4 prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003.md +2276f05f42f628925cd0b0834d7044e5934a488f85f4a57d5b22b023009ae17f prompt-packs/chatgpt-workflow-completion-next/references/README.md +4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/chatgpt-workflow-completion-next/scripts/archive-prompt-pack.sh +004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/chatgpt-workflow-completion-next/scripts/check-delegation.sh +0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 prompt-packs/chatgpt-workflow-completion-next/scripts/create-ticket-worktree.sh +040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 prompt-packs/chatgpt-workflow-completion-next/scripts/foreground-delegation.sh +944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 prompt-packs/chatgpt-workflow-completion-next/scripts/launch-delegation.sh +964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 prompt-packs/chatgpt-workflow-completion-next/scripts/restart-delegation.sh +19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 prompt-packs/chatgpt-workflow-completion-next/scripts/stop-delegation.sh +614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f prompt-packs/chatgpt-workflow-completion-next/scripts/validate-prompt-pack.sh +ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/chatgpt-workflow-completion-next/templates/PHASE_GATE_REPORT.md +c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/chatgpt-workflow-completion-next/templates/TICKET_COMPLETION.md +29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/chatgpt-workflow-completion-next/templates/source-baseline.example.json 838a6333e2fd160f71c8d3d6610a6e0521e412f92181f5332f3ceba1f696110b prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 prompt-packs/mcp-server-next/DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 prompt-packs/mcp-server-next/EXECUTION_PROTOCOL.md -9ead09fbcf8a8af2c954d9c1cbb95439a579c16ff5e7bfcb86a88b66a47ba10e prompt-packs/mcp-server-next/README.md -03bd1bbcac7331cc09143ad35b2219726a9be177b84810d0ffa7b1ad53c1340e prompt-packs/mcp-server-next/pack.yaml +40f034a20cbcbb928fec310bbe56b27c73104ea48108b792677fc26de352be64 prompt-packs/mcp-server-next/README.md +4a4b64b2de7997a23d3f36365c8afc05cef4462771a7a19bc653c4c705690abf prompt-packs/mcp-server-next/pack.yaml d4e8b40779a4293ad8f15face0b4cd3b37ec78b79f005b910bde644f329ec3be prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md -69d77a1833cfd23d085f3609511501d8d43f6818ca3bc842ab96e952b8b8cadd prompt-packs/mcp-server-next/phase-0/README.md +d9c303351fd8f94fd9191f5aa97b704609af94f4ffb45ae188c8d041f4cbce5b prompt-packs/mcp-server-next/phase-0/README.md e27d2cd479f66651472ba2a942b8503eaef5c23c5c7986fb06afa8fb2dfe03e1 prompt-packs/mcp-server-next/phase-0/task-manifest.yaml ea63a1bf8f3ef5c4ca13d866001776c3cfb043d6ccb80ef9e0663b9246cd2ae8 prompt-packs/mcp-server-next/phase-0/tickets/P0-00-baseline-and-preflight.md e037b22bd3df3c487a7b557f9867dc8177b85a13086cd259e3ad394382aa758c prompt-packs/mcp-server-next/phase-0/tickets/P0-01-primary-research-refresh.md @@ -130,7 +196,7 @@ a27a9fc70ecc65ca8cbffb728bcb6da1690df49515b5fde28f98e6593bee63a6 prompt-packs/m 434147effe66cbae328a54182bf85625a25f0246be8fa3c75595eb0dca901894 prompt-packs/mcp-server-next/phase-3/tickets/P3-00-workflow-baseline.md 902538b2ed4099776e9a8411f99a611cf5e864d2641ff40e3cdd96e10cc88d4b prompt-packs/mcp-server-next/phase-3/tickets/P3-01-workflow-aware-tools.md 613d19df7075e45fc90c137ac0a52fa843e56d09ab381a46ee43a79507fd1795 prompt-packs/mcp-server-next/phase-3/tickets/P3-02-independent-review.md -6f54b081f8882a654807e44df90bf6e4ca026fad073566f994d2f218302dd2fb prompt-packs/mcp-server-next/references/README.md +4346f5ab221a1a9eac21fdff24315c7c49d0a192b45f5afece8da8f48b591872 prompt-packs/mcp-server-next/references/README.md cc792c567a4900dd9f270ad5c7eec835d4b8bc6eed9353a31d093759476036c6 prompt-packs/mcp-server-next/references/code-structure-outlines.md 4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/mcp-server-next/scripts/archive-prompt-pack.sh 004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/mcp-server-next/scripts/check-delegation.sh @@ -212,7 +278,7 @@ d4932ccc4b6c4734745451cd76c439b42e78cdd36d7289d020bea44f5e5ddeb3 prompt-packs/w ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/workflow-foundations-next/templates/source-baseline.example.json -115bfee9f873b44d7346b69ef263a3241d5813504f6acd6bf4497b462aa908e7 pyproject.toml +874d8676b582dc2b075a1fec2f507f45cdbb0e16058b5cbd6a65f8b6ec229a22 pyproject.toml 3156d2a36c05428fd993983bee48c491d7792da4634a721f098af876c1c9fc5a schemas/agent-context.schema.json 4f4da8261b8a9946b477b694bed5610b581aaf3722ac8460194711ec4c685587 schemas/assignment-event.schema.json 260790055c6f13515a4dce9704c3847259eb1d664dba8b628bae367814223564 schemas/command-collection.schema.json @@ -221,13 +287,15 @@ c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/w d0150f14606effb18ba6524715659bfa36f9ce3811a7fdcc8f960d04f4ed7a4a schemas/completion.schema.json 2cad48f6b60762f99f0a2cf1a4f95f28eee9ca749d2d351782709ca2014b8d22 schemas/control-event.schema.json aa0cc367995b74db5d9670544d0aebbb14cc14c54b23bf469fb5916d3d2864db schemas/evaluation-plan.schema.json -3b9814118e31de75e8b5b9b4551481c14debd9574bffa1801c69938487669334 schemas/execution-metrics.schema.json +400a6ce6b8f19818f3e4b7454d6302bfd015f316f7c49f5cdfa9737d264d3d36 schemas/execution-metrics.schema.json 89893912dadfd53d8e4415cc93860b32960cdedea2aea487655ea0b80f0cc9f1 schemas/final-receipt.schema.json c500bfce18c4024d49b834c28dd3e7227de014b29e5462ab30487fda71af2430 schemas/job-binding.schema.json c1a619d566b4e538e1c98398349b67e6967ec5bea0b3007b405021eddc14f987 schemas/lifecycle-receipt.schema.json c4452d95bf0e60b5a137806a7be4486fbd557d092893ef9ba6b0a983b442ebea schemas/native-job.schema.json 2b0c916889366e9730474bcdfb6c104486c0230fea4c6ae88aebf56c398e3d8e schemas/pack.schema.json -82bea902a1aecf411c8d04ca55970fcbed2fe25c1e920a5a7f5eacbb2f6d1d39 schemas/run-provenance.schema.json +da7cab674e41b79bf6ce1371977a3fa3e78ee80b36ddf87e3c614b5253ae9cbe schemas/provider-evidence.schema.json +22f7e06ef2fbdb52af6a4e65daaa0d418b77265948455fb431055891cef67f44 schemas/routing-advice.schema.json +4cd34e30b97472dc4f545b55793e4a0ea8435ba63532ef37439ae423be47fb25 schemas/run-provenance.schema.json e56534075fa05d9683ae1c29c50a54b469f4447ef444c7029a12bcbfa6980758 schemas/scope-snapshot.schema.json 08315c541a704b3832f931f5db175bca0926ec252aa2046b56992a5195878fae schemas/score-receipt.schema.json 0d79fc336866443a38cb46c739a68b9bcb96389e113b20831967f3add6f29063 schemas/session-status-v2.schema.json @@ -236,9 +304,18 @@ da835fd07fae196f8d9dac396413e1c435725d0e1db0cb378ebab195b6609134 schemas/source 5435cb2705d260e8ca3fc5ceb334ecfc573a749af82e7aac3b38b90d7343ee88 schemas/task-manifest.schema.json 7e2a8cd2ac3e8718dda7d0509738f1328800a4e38f75ef8a21e198538fba07a3 schemas/task-result-collection.schema.json f7ae2d01e8638c1d057e772befebc465e1501d1ddc49f0c03a54bf1b09b59810 schemas/task-result.schema.json +6e55637f9b8d02838d9e74cb483e07ad49ec03cba4559aaa5c10c8c41e080f8c schemas/trial-evidence.schema.json +f86d1994a169f194e4acb983b7326e92a931a608709b6d71f3dfe32e2dd2a451 schemas/workflow-event.schema.json +b2187bbe04c39bf78055919c68c9f0a526ca36fd1b9e887161ddf45d596fc579 schemas/workflow-input-bindings.schema.json +568935a10565e39f63ae925e6fdace9f86df873981b20eb5b9899575bcbc8f39 schemas/workflow-node-binding.schema.json +2409a01c5b6ae69b48e763f8b3708faeddfb92e7b7d1d3db04ee50eecdc067dd schemas/workflow-node-result.schema.json +782bf8fd4e9ae5098b19770ef711d66e698936ae76009ba6ec4f3806c5f7db80 schemas/workflow-receipt.schema.json +982aa8256ae1900f961c067a351a7a062ab736b8929a5ff6c3f0f60aa9ff1b50 schemas/workflow-run.schema.json +7a00b7e98d67f6ed182dce2b62df035a05dbe7dda9fe5bc4ee3352dc0931728d schemas/workflow-snapshot.schema.json +f291140c90b98cfeb1ebcfed89ccaf0f273c2a1e0148a85d70e055e48a2eb569 schemas/workflow-status.schema.json 303f47ee6d2345c19b98511a79c8c5086ff16365af81b58886f2fed1d74fb4d4 scripts/README.md 4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh -181c7ca1e0f180f0894e958962c0f92f8ae4ded2c47ec47dc44f480b6cba75d7 scripts/audit-release-assets.py +fb28b072d332c090b0b85415435c12d440c26d78223235f67af0d96607c905e6 scripts/audit-release-assets.py 004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh 0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh 040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 scripts/foreground-delegation.sh @@ -248,13 +325,14 @@ f3de06fb3b15782ce2f9e9aadfbbfadbef80bce990476b81bd62976fdbbc91fc scripts/releas a5070c546b6621ad93688381e2113a8c0a915d87262238a327637236d0f7f158 scripts/session_status.py 19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh 614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -fbe3213df199116afcbf520ede6e7d154e2c53a9a3a8393e2a8b13c2b11f70bd skills/agent-workflow-orchestrator/SKILL.md -879d85c437e92068da06a5002dec975de6c689550980616e36d9858ed4f23960 skills/delegated-implementation/SKILL.md -98f04f4eb02cd54958a9a514bee6bde519862949e4fa4d21e2e86047e147aee0 skills/phase-gate-review/SKILL.md -b256d15fa70e9908aead505b5104e058b3d50f922ef1e6d2bb0fd129f1acea3b skills/prompt-pack-builder/SKILL.md -d585d6670afbd1055027857d2ce0d414b9652d92173aa71c538736ac7bbf03ef src/agent_workflow/__init__.py +be9e754cb7a5fe3f0c24dd268c84ce61e412b4f9470ad1512228f1f3ff20931a skills/agent-workflow-orchestrator/SKILL.md +9a8be67f1d197ec814493cda285c8cea12d7a8b02f3b7374fb75087097e39f62 skills/delegated-implementation/SKILL.md +0bf899f4b80c74fa937b92920528bd323bb388baad9758be3a502ad7c45e0fdb skills/phase-gate-review/SKILL.md +6fc060c52b099b8cb9da79826fcc8b2bf69a692feca6eb45fb9a37b175611e08 skills/prompt-pack-builder/SKILL.md +0fc0d8cd5126d9db40cced7b41ce1a7ef93578e108577a8ed557af959a6c89a8 src/agent_workflow/__init__.py 79ee6f134c5c5193377cf9b189cbdcc3e2095e49ba9cec310114441c15a917c4 src/agent_workflow/__main__.py 6bf4351172ab5490651bd768abebccf3a1c00d64d2a55d02eb6598ae311854cf src/agent_workflow/agent_context.py +fe5f331833d19f67aa23c1a01a50d7bd218cedd4304364558944d2be11be6bbd src/agent_workflow/approval.py c769491e526f7e1e892888540dd0ea11592fb6a0430c496043d009e00f9adcab src/agent_workflow/assets/phase/MASTER_IMPLEMENTATION_PROMPT.md 3941b941fa242f77f85a3db3c31047d2f2c800ad96a8d749da8a75f4965d02ae src/agent_workflow/assets/phase/README.md 2e1801da0db8cad81bf4698eddd70a72e556c718f1a9bd35d635f20cf0e0e6d6 src/agent_workflow/assets/phase/task-manifest.yaml @@ -277,21 +355,22 @@ ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c src/agent_work c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 src/agent_workflow/assets/prompt-pack-root/templates/TICKET_COMPLETION.md 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 src/agent_workflow/assets/prompt-pack-root/templates/source-baseline.example.json a72623d39d13fc7532983587c2af4b45f104a73bfc45f8129ad7d076c7642008 src/agent_workflow/assets.py -1561be24cc0cf4fc9f3c10cd24b7c588a43c62c114470658a48e44a51ffc9dd1 src/agent_workflow/cli.py +1bb5a1767626d4117f94b7ca10033d517226884c9eb73ce1119fc7dfaaa9f79a src/agent_workflow/bindings.py +f723d20784e58c3ca39a55972343032a6fc55d0ae192644d38b378cf5f29a600 src/agent_workflow/cli.py 228d3b1bf04a6681ba138712d91510b64c0e97c1cf76de2cf9cd23e8626a37bc src/agent_workflow/config.py 2ce05b732595d51818c28ab121a75cede32d3de354de0da588815caf7c71f9e7 src/agent_workflow/contracts.py 8c75b2df9f1fa58afd914d3d74f23524b3be41c1c08573e0ac5b2d66c1d4ae63 src/agent_workflow/diagnostics.py -2a2c77724f1683c12993bc96c6e294ee433bcb20660cef510676e46232ad77ed src/agent_workflow/doctor.py +a3e24230389be62dae1312f674bffd4e77dcb960c5f35f825229782d62b19e26 src/agent_workflow/doctor.py a08ecdff9fc1860261166deeb03edc4b5d7f4fa98d909b4e6ed211752fd2950c src/agent_workflow/errors.py e847deb2e0e5f5f015f718aa6d0cc9c558efc5251d4092a6abbbefd926588dc7 src/agent_workflow/eval/__init__.py 2845344237e92ca8d36d747adb3203ebc395ad932c3ca05c5b0d3ddf0d476546 src/agent_workflow/eval/commands.py -4090a25b639f9406e82693fbc7690038a8677ac81b8c233187efda490e722b1d src/agent_workflow/eval/compare.py +50b3cf58db4d8fd836fb3f486120f857cbdaf33354dbe9c9003ce57e2fd39dfb src/agent_workflow/eval/compare.py 190a6e743b7a41f99eb7d29e42a148243829126a4f8524fa7bd6f4aed99cbf86 src/agent_workflow/eval/junit.py 51b10abe5b5c5941a44824ba931855e1e2116d23d7a1238585ea9f20210dba5e src/agent_workflow/eval/oracles.py aee93d9878f44ff1e1cd24117d44924503bdca192393674f85dbb0171647ea58 src/agent_workflow/eval/reporting.py a379c27cf3be1757d33b749957a01b4dd0e7a3eb9bc6c3b83f5607209dacbd17 src/agent_workflow/eval/scope.py -87f86fe084d1f776cfd62661606ba828b401a6f4c669aa4cbec2b3b203bfdec6 src/agent_workflow/eval/scoring.py -9f5bbf84036589e17ffeb693222416a26355b58130e8d62209ad9458896f2bb2 src/agent_workflow/eval/trials.py +383ef56f0814ff2a4671bc44f135f7232cd464531be291c1d41b2da6aaa0ab22 src/agent_workflow/eval/scoring.py +ba27c19ecc466f37e345a99b16fc93736f624bdf493cdfaff0644b22fff1e9f6 src/agent_workflow/eval/trials.py b2ae17c4df4659b18eee5b6d134300ecf93edc389bd646d53000edf0a1ee5b4b src/agent_workflow/evaluation.py e0e6e3eb13e67a203f04e03b8e50cd72174be9e95242de228c9c8b4f36ac5770 src/agent_workflow/events.py dba9d6bbfcf81efa907d7d8bc1e23e1ef3903699977da3c9e41989b96a71d35e src/agent_workflow/executors.py @@ -301,167 +380,33 @@ dba9d6bbfcf81efa907d7d8bc1e23e1ef3903699977da3c9e41989b96a71d35e src/agent_work 1fd42769030fe7b4833403a069fce0b77593a360fd0d8366af089bd7f3441b49 src/agent_workflow/integrations/mlflow.py 71bea9e1a06d848dfed7cba24d42910fefde32cf22df59d8b0d3d7a49a0b0b27 src/agent_workflow/integrations/otel.py f03f4113aeed26c3f30b412352c78f01973cd8e48639dae6e3c5da6ca09761de src/agent_workflow/integrations/swebench.py -f62c7bd12407932f523cf3fe640805ca652bb114d4bbac2e2c51ac5ed32abf2f src/agent_workflow/ledger.py -a1c912601c5003e21f6d58e65248dca5d84d0e4f821b304c979660ac83af71b0 src/agent_workflow/lifecycle.py +00966ee64b9fb2c472115fe331fa9cae5a4e84bc2729696cb603689340d27ae9 src/agent_workflow/ledger.py +8d97edb037b1476d1f83d7bbdd1af777959245c1899c7b102135ec24008de82f src/agent_workflow/lifecycle.py dc0a3a80bd2794977f391146bf287d41665f992eff99d75467e90c6f83012948 src/agent_workflow/manifests.py -3adc28eadb33926c91691d01e1deb02d115bb0285a4a88cf2f9b4a9b1f1c01a7 src/agent_workflow/mcp/SDK_SNAPSHOT.md +b273f5ff4143249cc63016e32ad543cd49701adbe37b2d14f2bb80771b95f846 src/agent_workflow/mcp/SDK_DEPENDENCY.md faece6bf44157584c18460ecae7e346a497cd4a82580d19a5a489d6f13e57113 src/agent_workflow/mcp/__init__.py -8be058546657d7e726b672e98805a0424e58b8c03eb20ef69f3169014abcbe59 src/agent_workflow/mcp/sdk/.git-blame-ignore-revs -d8a847cb52b1010c50bc614448e75fe42fafc6869aaa0b0aeb8d11d1272556c0 src/agent_workflow/mcp/sdk/.gitattribute -d8d38a07e446b2a66139c0cb44b8cd069c11e3cc08297f160143e33c78d16a05 src/agent_workflow/mcp/sdk/.gitignore -5f50e86d09d15eccab9643baf17ae4c2d34a1195b61fd929a305f20cfc4e076d src/agent_workflow/mcp/sdk/.pre-commit-config.yaml -c2ada1b323156666f835799040db47806c683eadb12d9c43b52a106a104b5d01 src/agent_workflow/mcp/sdk/CLAUDE.md -edc7cdb1bd8e7ba5912b76876950116a73a0f5c084c809f704c9e2795eec805e src/agent_workflow/mcp/sdk/CODE_OF_CONDUCT.md -e990f8adee9121f3dd7e5213d6ed5eb24360a7eb8c742d43e3ca59537e6e64db src/agent_workflow/mcp/sdk/CONTRIBUTING.md -b8dce85c69511c8b8014b99506f2a024d5d8ff6a50744201df561e4e5a451e26 src/agent_workflow/mcp/sdk/DEPENDENCY_POLICY.md -5e13dbbc1d120fc2a03cecde7c91424ae2d7de11b63d58ded2f4431e261ee50d src/agent_workflow/mcp/sdk/LICENSE -0fd24eb2c757acfd9e52bc9a742ed5956bc593cc57f512a0c98d8ba55aaa2648 src/agent_workflow/mcp/sdk/README.md -e4bf333a8ce4cebbc72f0c9697409fc0b69955d84cb3d1bcc79d2e07ccddcba1 src/agent_workflow/mcp/sdk/RELEASE.md -f87d4aa37af9203d6e10b93a152dad093062ae5042c20b769d4c127ad8ba7180 src/agent_workflow/mcp/sdk/ROADMAP.md -4e9bd21a3b8cbb8c45418242b50083ec8018aa5c7cb3fca0f139733613202cff src/agent_workflow/mcp/sdk/SECURITY.md -0abd203cc31c44d5badeb5fa62eca7a2e21257f52674ff94e8ef23a154812763 src/agent_workflow/mcp/sdk/VERSIONING.md -962158fc7f761c61c7f27c8d1a130f2194454cb9214282360cf1ff649990ac42 src/agent_workflow/mcp/sdk/docs/api.md -a440bff8d98d5000c2bc1434cd7ccdc4d66f6b96b60d867e4820373d59c07573 src/agent_workflow/mcp/sdk/docs/authorization.md -e046f290d043f178bdcfade9a360498adde5eae1e35ceaa06da591451559df5a src/agent_workflow/mcp/sdk/docs/client.md -f498ccd8e7365b5a92000c71b6b560a17e133d6ec206d9ebdb2859d387c22720 src/agent_workflow/mcp/sdk/docs/experimental/index.md -b2f4d9ed5263dd0f6108ae516b7fa92b324601df6bfd9767275f075497e9bff8 src/agent_workflow/mcp/sdk/docs/experimental/tasks-client.md -169d9dd7dd246bdc5696e910a795eec62f6b5043970e1d3c9a0bdab7ab15d51c src/agent_workflow/mcp/sdk/docs/experimental/tasks-server.md -e50a64562a7bcbb3af2c9eda6d65587e709cff8bf002219417f0dce01a8cb9fc src/agent_workflow/mcp/sdk/docs/experimental/tasks.md -6b98f10049a2d7ba867138ddefbec8a9907c2cad58c15c364281dfdb48277933 src/agent_workflow/mcp/sdk/docs/index.md -04c6a10d437e53aa02a0edaf5d7f0a9cb361c41e25d95e4cc338808b47d446bd src/agent_workflow/mcp/sdk/docs/installation.md -db72e2fc7e373eac1c641622e8abb0149c1eb728c7e7610e640bc082e8fc1726 src/agent_workflow/mcp/sdk/docs/low-level-server.md -a1f0692a15eb9129d7187683c5ad05b346dbe0195a5820fcc9bfedaa1e414328 src/agent_workflow/mcp/sdk/docs/protocol.md -02fb5b28af6ac8572f347ed5b0ac5e0a8ec141e2865ba2b9fc13a1f2c208dd69 src/agent_workflow/mcp/sdk/docs/server.md -3622b14cf1432019576d799b0a06fa3132db6b294fce259105359842a8c06d83 src/agent_workflow/mcp/sdk/docs/testing.md -d37347ef5ee62d951d628219d8a2a2ff0d89f88b8d9a99a30673c9d43b42587d src/agent_workflow/mcp/sdk/mkdocs.yml -c284ced18c77283c3ecfa6ac007189d57159c4defce6f6f56489318d764dcf80 src/agent_workflow/mcp/sdk/pyproject.toml -f66ae17959a1420edb67624dcd79da20860543e1d513b7d5c86ec0250cf8af72 src/agent_workflow/mcp/sdk/src/mcp/__init__.py -349ac92144268dce85b52c6f18db75cde5e748b8fcf8c962f3a95a3834ed4331 src/agent_workflow/mcp/sdk/src/mcp/cli/__init__.py -30a52e646ac198c4ac943db078b23153b45d417665d8ebee30cabeffe1009606 src/agent_workflow/mcp/sdk/src/mcp/cli/claude.py -c3a996066732e32ee7c4b4036b8a926f1295d0159e26a53f48565fdea74eb9bb src/agent_workflow/mcp/sdk/src/mcp/cli/cli.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/agent_workflow/mcp/sdk/src/mcp/client/__init__.py -fda5d8277c498901944711047ef57ee0c02ac51270205a6d15a2c7c8981fbd15 src/agent_workflow/mcp/sdk/src/mcp/client/__main__.py -86f94f7256839371241ebfe7aedeadc77ee2bffad0f12420a2c3343a1252790e src/agent_workflow/mcp/sdk/src/mcp/client/auth/__init__.py -b940c75ff3f44b747fb6dee8012f002d0ad480e895d8381cd1040075a681716f src/agent_workflow/mcp/sdk/src/mcp/client/auth/exceptions.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/__init__.py -5ef09aac14270780d112e6045728196ded0ff568fbcfc752c7fdd2a9a8cc2b34 src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/client_credentials.py -5f5c34780e3fdde9fd5d572271c9196ec7bd2782a25805f668f454a95213d214 src/agent_workflow/mcp/sdk/src/mcp/client/auth/oauth2.py -326e38de48fda47634f665187e59abf3d5bc8dec596a5bc34bde9bff7d6d5081 src/agent_workflow/mcp/sdk/src/mcp/client/auth/utils.py -afddb740f32b1d06cb41803f178f0c2f2d98f0e0dd3d6261c7c4aece15dc6840 src/agent_workflow/mcp/sdk/src/mcp/client/experimental/__init__.py -c83ac2a220a16b9881055a63a99ebaba4dd9a03890ce7a94dbdc4a198e0387f4 src/agent_workflow/mcp/sdk/src/mcp/client/experimental/task_handlers.py -2cb11b7e623008d9343c2a4f4a784ae94d1d71aab110d6ddccb417786b2a5632 src/agent_workflow/mcp/sdk/src/mcp/client/experimental/tasks.py -92f812af17983f5a36b4703b065216cc49252fc59dbd04b753286fa78580b07a src/agent_workflow/mcp/sdk/src/mcp/client/session.py -c17b78b1923e5915ddcb93cfbfe84dc1184cbb312128c9107f76c5bda3884c47 src/agent_workflow/mcp/sdk/src/mcp/client/session_group.py -99420030ad50e4c209d877ef469f91df457a9d4a62f88d1f77dfd4b4b79123d8 src/agent_workflow/mcp/sdk/src/mcp/client/sse.py -a23227cfe1e3456f16ab41311486a399cf547fcc6b645399bf9bf3a6dc6cf654 src/agent_workflow/mcp/sdk/src/mcp/client/stdio/__init__.py -8eb01c3d4da89c6e73d494a96029dbcf2fe05d0bfffe76071b1e409afdb62f5b src/agent_workflow/mcp/sdk/src/mcp/client/streamable_http.py -2313b604f39026bb157ab9d0064c5ca736cf9b00d2102f936f68a660e833ab18 src/agent_workflow/mcp/sdk/src/mcp/client/websocket.py -baa68621f085e08d083de4ac10464c37066e0c5f7553458a0086c370accf44ba src/agent_workflow/mcp/sdk/src/mcp/os/__init__.py -97fda3f23861830ebdb3ff9e1f87420e4e10631365c4a188a5a382bdc6efd509 src/agent_workflow/mcp/sdk/src/mcp/os/posix/__init__.py -99311857be0cf73f6cd9d79759cb695064d2d4c36d90eafb2180975379b194d2 src/agent_workflow/mcp/sdk/src/mcp/os/posix/utilities.py -ef1c88eca5a96610d2de898610172edf6d3443e0e322c4bb73e6bebeb9f6f18e src/agent_workflow/mcp/sdk/src/mcp/os/win32/__init__.py -3398fd345f50860087a9078762973dcf492c16c372d45e37bd8a909772d7abe6 src/agent_workflow/mcp/sdk/src/mcp/os/win32/utilities.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/agent_workflow/mcp/sdk/src/mcp/py.typed -8de323024bc286128b132d5fa5e289e1dfdb4f5be79e6b869b5d68b50ee77018 src/agent_workflow/mcp/sdk/src/mcp/server/__init__.py -29a9144aab85ed1e38520c20dcde5540226c3e7b0456b6f093e5274477ab570c src/agent_workflow/mcp/sdk/src/mcp/server/__main__.py -c66c41cc6018bde8ce133c6abd9c62464a28b48765a69e56ac23623cabee29d9 src/agent_workflow/mcp/sdk/src/mcp/server/auth/__init__.py -f188e57a324f97ea8c8d9adb2530f330f04e00e2139f40dad290b94169fd41d1 src/agent_workflow/mcp/sdk/src/mcp/server/auth/errors.py -4f6b9e4165692c36cc20088e7113b2ba6c51c2a6bc3e8e6503dcddfc26edd947 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/__init__.py -7c0cfe2fee50bfa29e1e3a6804e01413ab18813b86d3274f03bcb6c636f8a483 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/authorize.py -9963e4aecd437c6f90cead67654d87cf0c52c24f0afef5def99a440d9d24fd00 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/metadata.py -0ad1fd734de866d260528055afe560ac0a09e965c1c36446364e67ef478194f7 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/register.py -0c85639f4f64eea4e4017ae44ec44156547e5ccf6bb264c9cca957c8d749a831 src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/revoke.py -cade6452f07cce106038da586493904c5ca82590610b450a3d4137190324a2db src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/token.py -10cbf7a801fadaf46ec3c5c11dd154de4629d12f0977a0e34467f073b25cf575 src/agent_workflow/mcp/sdk/src/mcp/server/auth/json_response.py -5e14548bfc3e89efd01bb5d3e447ea346f8d3d9bd7b49b601d207fb4f558348c src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/__init__.py -d2160352bd862f456a4d542d0826420e8a762701a8c7f22e0f45c16506eda53d src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/auth_context.py -90451121f43c0d6f44d56339897ec5f022f77de89b9a7d258da1b4f6d6f2f8f4 src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/bearer_auth.py -948206b9f923f2bc243558e1eb7b4f1f550e63987805cd3cceebc69ae6d4cf8d src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/client_auth.py -32c497b64bc8f3ecdeac33376405c397bd3ad2f8cf44463768cb6f2b5473a007 src/agent_workflow/mcp/sdk/src/mcp/server/auth/provider.py -8f80796f63f0d9526708eab76d6dadde646f831277a08e70cd09f4cf7025679d src/agent_workflow/mcp/sdk/src/mcp/server/auth/routes.py -363f2d3425c0347302997ea7c426be194ac0f649c9f5ff330ae33130314de03e src/agent_workflow/mcp/sdk/src/mcp/server/auth/settings.py -7aeaa3a2d8a906ccec8ab759babaf67db29e99163047bfb39599628f031d48d6 src/agent_workflow/mcp/sdk/src/mcp/server/elicitation.py -5d3d1d846fdc633a3db12b5832b9913d238867004b77707175840882ad75cf32 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/__init__.py -8ea72feda112d12de38335d230fc271864627abb363b562ddb4f5a0be50f0717 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/request_context.py -372207d92d98e9c36b5bf5f88ffd83315e133c01304c39f73cb1fa7963f21de4 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/session_features.py -a45a384e2a7a96a83dfaa88a44957f4dc1ccc52d0ef26b2340bb6740e32187b9 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_context.py -e41d8192e6a51f4006303569f7f18ab99cc71f0024af994a0dcb8edf1b9d19c9 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_result_handler.py -ca335edc9eef691a5b200caf952ab0b0f9e846a192e129e8026ded484eb42021 src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_scope.py -cbb97d8a7d0914b9bb68bd1fbaf1799ee0851de338fb61df53e0c4a1a011637a src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_support.py -e049e578061d3c5fe180f7b17eadb3c4646d53f15ddd9ae35de80a724ba3f8af src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/__init__.py -abd7635039a9c2618459c1c8f2ae14cc906d7fbb3b52d80bfbe381ece686cf24 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/exceptions.py -e01b0cc68628969a3183be319249330852fcbef7642d52797083cdb35343d49a src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/__init__.py -b39fb3a428df77e2cb6ea3286b6a099f3528554196e045618bcb9fbd64a31e7b src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/base.py -21722ffff9ae390857822a4298a589e1cc8f8e36b05a48e679a85ee86dbda325 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/manager.py -7b84b7ebd8c1a09b74edecfdfd968979fcc67f3e24afd9c6c06e0a3ccccc1dcf src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/__init__.py -6b0edde3a75f3060b3f5f60db52871089f4f9673469a87afe097cfa6cb42f312 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/base.py -7ffc92edfe3e86cf91af288b1e5c5799febc6c77787199c83e49976f0ce16958 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/resource_manager.py -74b7137e8115ea7bbc0a40eaed6b0d235aa6c0615a9f898c7b0bd852ba0b3cf0 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/templates.py -83a6df5c534619fa0e1a2ab7ed332f1bcee4c57ae08d2815e94ccb4b833a2f3e src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/types.py -6feed1a5c112acf1cf3f35e887463b54920c695484bcfeea29f86b6f4c2e0127 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/server.py -65ba318723090e5f3b4af8e8bfc630358c0d662e793fd5619a94e30592deb279 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/__init__.py -87626ba624d33218ffb7542140dc888453e33130fdefeadc8909d47bc600ca54 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/base.py -acd8c9ebfba0e8764426e60868deeaa931a012a8824e16ba4f479e4991bd2a76 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/tool_manager.py -fa2989f12fab5e66d73167836a695d3fe7470ea00f83fc3098f573f8b357d781 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/__init__.py -e4687f9a7e316840d020d089fa3987ca004a9edf45f84489da1870eba2dc1a4f src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/context_injection.py -72f72f1dc9195b91280b84c2ae8db466e8c289ca3069e18dfa8dfeb0df59deb2 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/func_metadata.py -7bc9011c0607074d209ed31369630deae4f89308a239d2f5e2a0d7119534c7e9 src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/logging.py -c5746c86eac64488d59ebd6554fc1c66899248de3fc2fc6935bda34ba4540b0e src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/types.py -992eec96b5685b31cc62a0c70c827ba11671c0f646fc8eb751542224ddf5d586 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/__init__.py -2b24d11a8742719c3e827facc673c5b1aa7d8ec8a6e5f941e7be892d7a5b3ea0 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/experimental.py -b8b12963920781689c21f6d86da23868361f520311c6e15d4eedb476ebc7da9c src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/func_inspection.py -2d50b437a24c3f29d5b387cc2527c19aa2888a01cd2ec44d841bdb534eab83a0 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/helper_types.py -1e8df33dc1c8084891cabaa94a6c9ffb46ea054126433e0f8596d290b79ccc24 src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/server.py -c3ec443f2fea19db36fdb1297a337a89a59e87452f32d2aac255f33cdfc2859d src/agent_workflow/mcp/sdk/src/mcp/server/models.py -4acc11739460553a2b962660f05ae6389ea000bbdfdba5945ce4fb1e1eca4598 src/agent_workflow/mcp/sdk/src/mcp/server/session.py -e95d03b40ddb68968d5cc1b0a386c378197f54410709123f485564f05e4fbd77 src/agent_workflow/mcp/sdk/src/mcp/server/sse.py -1469644bfc9afc3a230933fe2fbad8846ec5f2dc13f6392826bcb060e8c94141 src/agent_workflow/mcp/sdk/src/mcp/server/stdio.py -b184f85a99c9bd3aacc8232f6ccd2cc24cbfd3964ecb23ab2c9565d9aa38b225 src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http.py -5fc265202379607164d7711b43dcee51cf087b98c60a4ed5555045a243f34458 src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http_manager.py -3258d3d144cab8b472bbcf35526e81b85ea08ff67ddefb25616180fe121355fa src/agent_workflow/mcp/sdk/src/mcp/server/transport_security.py -a30616302120e4a2deb275e635bc9a4e27c9c89289629efd16ccd780b3ec1bb7 src/agent_workflow/mcp/sdk/src/mcp/server/validation.py -6ac35e8eaf72919bacbacc96075ab74555faa7889eb1a85d2fca376d2ba1b593 src/agent_workflow/mcp/sdk/src/mcp/server/websocket.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/agent_workflow/mcp/sdk/src/mcp/shared/__init__.py -6dfbf2a6ae73b1bbcbe1d82d35f5fe969f11fc5aa6cc3b5610aeb52bb63e4a8b src/agent_workflow/mcp/sdk/src/mcp/shared/_httpx_utils.py -491c6ce44e7c264b28ea96654f2fac0c55f10c28b6a73601ffc8066f3cf6e664 src/agent_workflow/mcp/sdk/src/mcp/shared/auth.py -697d65d52de90e513ff6bd05bf6e48efa86e269d70a81db989444e6f6e71bae7 src/agent_workflow/mcp/sdk/src/mcp/shared/auth_utils.py -40c447c1708826cbdfe14def720a9201769a50430a85f8fff5794223e9149271 src/agent_workflow/mcp/sdk/src/mcp/shared/context.py -8251d85855667489e11d19e5c2ba6be70fe200b2ccd0e94d6403377633410d78 src/agent_workflow/mcp/sdk/src/mcp/shared/exceptions.py -061843d7be8bbff3662d9de72ba9f7cd5c826c2c448f954cc0fbaa8e6ddfa9cb src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/__init__.py -8d80a6fa66efbc46e5cdc49612f40c4e56d10f789426cb3e3c6178b0c23d38c7 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/__init__.py -c243bfbb8ebdf28e7262d57c44f0918f4a1a2967d02cc93b974e7e2027631a6a src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/capabilities.py -6a7dd7067a1c294c1d94a5fc851ef5c28c330e2c412b0b147bdb82057aecab5c src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/context.py -385ee86c75e9568426aea7edf997045ce03d6d8f311ef9cc7ddd981b3df178ba src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/helpers.py -72608fa39f573903defa4179b86d5877eea71d90da3a1ac464965cdf9bf723c5 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/in_memory_task_store.py -bdfe439b06fe5e4a72b5b2ba4e2c68b855a3f1c8b4c3178dd924e98fb214a22c src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/message_queue.py -e8109ba3b858186d7d6c6fd125462f9d82c9eb958759b589f60c2b8bb022c3cd src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/polling.py -e55bcf7b197070bd5366ada3ffb7c074cce26256c70cd1e4d545b89758551f53 src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/resolver.py -e79d8a6e9ed4a8ba08aee3d99eb224645bb99b48a2433d2ab8db8738a23e62db src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/store.py -0ed3c56df6a5075e0d9b98594b9cd92c0c2b224ab064555c054c05701014d533 src/agent_workflow/mcp/sdk/src/mcp/shared/memory.py -2bbc32fdf09a3e8ee9d2bf3fd8f776db701bd10f4330006e0a2b4adf1ab47102 src/agent_workflow/mcp/sdk/src/mcp/shared/message.py -f53a7d6f43e47e30619b64a5c079e87755b805feaf641d29e49d84ed2bf6e54e src/agent_workflow/mcp/sdk/src/mcp/shared/metadata_utils.py -62ce15fc34d0858b2e83a292e816f71384f4083c8e39a21e7085c020c16cd6d5 src/agent_workflow/mcp/sdk/src/mcp/shared/progress.py -7a7f4cefae301873645a5e9d9fd2a2eda9884427251da2ac7db9d37e92fd291d src/agent_workflow/mcp/sdk/src/mcp/shared/response_router.py -473c151ed51ec617466e63b525c42a0f31db997c2812be78477deff40e3e9ba0 src/agent_workflow/mcp/sdk/src/mcp/shared/session.py -99312f833b0cb246b2ca294b68890fee521a0388def1c9e48fdc5821cea1b60a src/agent_workflow/mcp/sdk/src/mcp/shared/tool_name_validation.py -18ac0ce561c0c1dd839ababb4f2ac01c41968533bff54289254982efdd4970c9 src/agent_workflow/mcp/sdk/src/mcp/shared/version.py -6824109cd1eabaf43c5dab829a83015b1b88ec9ae149765e6814afaf23f9bde5 src/agent_workflow/mcp/sdk/src/mcp/types.py -adf5be246231b1cd7874b41cd76440f82080c9c15a8cc7ebf19fad6c4f99516f src/agent_workflow/mcp/sdk/uv.lock 8a0d0b4e56d35bf542da827dc7dd98d814044b384994999629126b4b79da6080 src/agent_workflow/mcp/server.py d1c691122a966840bfbbcc518dd76ea48b6134e23fae7f8ac639ab326c387aa7 src/agent_workflow/mcp/services.py 83a12421c93a0bc30c9bae04475cd88b18b5d1a83cc2f865580879fa74864db9 src/agent_workflow/messages.py -7ca54015c7e1ac1aa2001f6b37b34e01ed3995ae94943291f9b5fde3c123a614 src/agent_workflow/metrics.py +6356e12184d82f4e6adc9c2a21f04265221bf267caf44ed604e3b70e1d8a8dbf src/agent_workflow/metrics.py f5acd174adfd59f9461354c01462fc2d81b17f8604d16e9bb49825427ab00a73 src/agent_workflow/migrations.py 5e68e538455506d866da39a38c6d0c83ebc800f62cfc43cb44462d2b65199d5e src/agent_workflow/miniyaml.py c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_workflow/native_jobs.py b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py -5b9e31b81ba738445d5f0d2c26b73122bc33442b922b3ed11847587c3b4130b0 src/agent_workflow/receipts.py -720129b2ee184efa3cbce958a408a9adc04ccc6eb992a01cf5fd7662d24bb3e9 src/agent_workflow/runner.py -b48c2035bc4516bcc2bdada302b0865d39eaad9216fad9b0a56a9f9894025996 src/agent_workflow/sessions.py +f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_workflow/provider_evidence.py +0e72bed88f364dec5cf06664e181df1b1a5b3f2a7f3f5fff8a1e98a3ece7ca08 src/agent_workflow/receipts.py +0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py +5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py +e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py +29bbf530a8adab93c853b5bd9c4db8fff1e6d5b9ddfdec4b8872574ab02c1f14 src/agent_workflow/sessions.py 869137b2224fb9d50e5a652b2705f5442a2145cccf6f83c52452aa2045c3acc4 src/agent_workflow/state.py e0e438af91d4b5aaefc0790aca9b87eea01ffa114fe71f4debc64630c534dfd3 src/agent_workflow/tmux.py -2d7d0a59888c0850d313c3ae214228f44b18e283468ac5f6f313a5100084cc0f src/agent_workflow/util.py +063beff32bbbc2aef1c06f3843e00e09c0ac5f6b92663594f734a7386f2c4ff3 src/agent_workflow/util.py +a7cda2edc99f9c895d5c9ce05a6de93e8dbc4c96fc7e3604c7057f8ad466e1e7 src/agent_workflow/workflow.py +42e3d67aca21590b450f091fbd6c99630dff881e5dee6825d5f0986e8a5f38e1 src/agent_workflow/workflow_receipt.py +022079a7654488e8bacad05dc46f60f22b628d4f9a50354c9bbc94911bf69da6 src/agent_workflow/workflow_service.py +d4c855928723b17787fc82fff3c50b52e05ba3eff0614a33808ecd8196f3112a src/agent_workflow/workflow_templates.py 86784408e230921b3c1f3d8cce8764393077adca184c5508e47b0647ff4043f3 src/agent_workflow/worktrees.py ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c templates/PHASE_GATE_REPORT.md c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 templates/TICKET_COMPLETION.md @@ -492,9 +437,11 @@ ccb2388f9f681aa9d08266a539cdb5043347675293f24be3e7336b27b4ccd649 tests/fixtures 774b2753a43646fbd3641ea9e5bb2f4e8ec94f92a5546475ab6c99d6a13fb2c5 tests/fixtures/regression-evals/deterministic-json/mutations/no_dedup.py 582d7d19cb0f85e9be6064892d1d6cc789457f8a9e1762a66a0cfe24b5a1036b tests/fixtures/regression-evals/deterministic-json/tests/test_normalize.py 8b06992e66ac62ecfe25d45908a0cf0c7d0777f8c5cc12ab9d0f1cb3f7236c95 tests/fixtures/regression-evals/priority-picker/README.md -806c467e9ca3260c3888e0efd29fb89d5e3cdfe5ce5c8a0e1269279328c34435 tests/run_fixtures.py +0b3b076fe0bb92899828bc043e622644e7d1e291da70823227e378ab1e0dc39e tests/run_fixtures.py 611a05d753eb5442fc3d11723a5160dd3b567ffa903ca025e9a53e580bfef015 tests/test_agent_context.py -e775c66166d06177211531c054b749cd66e34adfa8e03f8464f9c8460268495d tests/test_cli_parsing.py +6c31e9e3c996efd24d4f69ef5dfe5391e36f7fac183c7976445716f956d081a7 tests/test_approval.py +98b9abc8dbef7620e0143bd41df0cecd45266417ea50c3ca3e99d1ce1282ccf7 tests/test_bindings.py +ee9b9a8bcd837e2a134c26926a5cd92522b031b10e8f0f6f2589b73c27f30e41 tests/test_cli_parsing.py 0e1420cd9081597fd21a7526c22004f57dd628bb7fe935718d9056b305b72e2a tests/test_config.py 2311600db15f6f01e9c702ba778c88e15547b670e64abc3c67bf47cea8479881 tests/test_contracts.py 528c476155cf5d833a7c7994c8a04328111d09f9147ff34c4048ddef6b825734 tests/test_diagnostics.py @@ -502,33 +449,38 @@ e775c66166d06177211531c054b749cd66e34adfa8e03f8464f9c8460268495d tests/test_cli ec670d1a68db0f2dbfc7e55398912cba1926e4e817d5b72aed40f2538e7a985c tests/test_eval_commands.py 197a1a9b0e0f75774b7edd24be9491496a2c025f7c69a554ce29a4fa8b0a85be tests/test_eval_compare.py dd29572b5dcd84bebcbd617608a88f6bf197b4c2bb3a0e5000cefef83f8d0316 tests/test_eval_oracles.py -27f1607408be491e1e643505d7c700257760021d8d038b31df0e1731516f0641 tests/test_eval_reporting.py +d54d87416a4001ce2622fac1293c3fae1786ab66a326852795b5cd725299b966 tests/test_eval_reporting.py f68ebeb52bdc204611f4aca5599176a88d453d08ff3047fe5c67ad8f7280ac3b tests/test_eval_runtime.py c1f9502ec2e3131f508e1a7810eb6663e184b9bd55ac6b368c199d25efcda1cf tests/test_eval_scope.py 80bb5450f6147dbb1cbc7f5667a986a66a440b35ad6bd67c549f93e9652e9de1 tests/test_eval_scoring.py -3b65a81b1c5e4f75255d115bc987a8b0c7d9bc2f7736ff836e9a5f9904ca771c tests/test_eval_trials.py +eea780818b433f541c68b38fd428ddb5199f8c8bf8759f110f19c7cd0f7d8506 tests/test_eval_trials.py 6c970efa532bf8f466e9d80e0356d8856bdab77a7e2c3ab0f116da5efed1b9c1 tests/test_evaluation.py 6d663358943765a04c4c7fedd0f19934c619771ac2f5cdb1a238f115c2d3451c tests/test_executor_events.py 2eee31d193628d2f690ded1fd76a3aff2e9ff7847702430a36cb5b0b9a60a908 tests/test_inspect_adapter.py 30d2ad6c538a8fb84bcd7131c585072a41a84f9cc6be3b1f2fb80e13a1b8efd1 tests/test_install_uninstall.py 0a383d6b81baa18f11c93dc3ada8642fe50d88e37390fd74060cfd776c0f5f0b tests/test_ledger.py -2bc146bc0c2c479d3489c909660aff987919303fe341c697ca8b85cd991ecbc9 tests/test_lifecycle.py +677275ab326bbba27811df62b7301f7cb0032ee9e71f70eabe88b7fd4c093a39 tests/test_lifecycle.py a5d4eaf6cace4f79ac486b00807ab49a2b5639267c67c7a086be146048ef7500 tests/test_manifest_validation.py 263bef2d552bbc60f22d0944588815a1782bd4f659909c3599ef3abc77a04727 tests/test_mcp_server.py f6c1c6a323351887ad92878013bf10420e553deea763f35394ba385e44d6e14c tests/test_mcp_services.py 3692951404f51194394f4383a9122c38e90d1fec47182f49a6fedce2bf13bb4d tests/test_messages.py -1d4ea8f0e09e5937449de206f7f4ed32c29f14e15a83fab72272b923b033c294 tests/test_metrics.py +e51115c54349c2dff15e59558e2b4dfde741b4b0ff3e1ad06b888ab0d3e56291 tests/test_metrics.py bd78795706c81b1a7787c5cc89408017d5f3da9da58ce46fff6a264028e8cee8 tests/test_native_jobs.py d8d13781ef4a508f985746f2d2ae4ff22dbffc1feb2c5088a62d6f488afb5a81 tests/test_optional_integrations.py +4735fcbdf376ac8bba98f8585eaa6d46f479d8383261554f43813fd76b0f949c tests/test_provider_evidence.py 3e3030a1afd810d9ae097fc146fd100738986152eff907d404665941cb8c7dc2 tests/test_public_eval_fixtures.py -a21d1e6d379b488fd7777df0ca6fdae4e0d44f49ac213cb4b47a993de3e376e1 tests/test_receipts.py +84681c3cb4407822aea13b6bc57ba25b3d3283e30eb183814a7a460ce044b2e4 tests/test_receipts.py fb75a2b8367059cbaf1a072fab979cb4e70c009f069a44da081efbbcddc7972b tests/test_regression_eval_fixture.py -e3ae9173a0308d515abaf0b7679e1765f2ef96802c59c197abda7f6affc2dcf0 tests/test_release_assets.py -3663ad3118722956e9a1b791c713eccc2bf228b516a282c7f8225b481acde322 tests/test_runner_generation.py +dca5f2619e1d7c8faaad4b36fd3e19d205ca7feb2cfb1678cf6bf458af7f2f4e tests/test_release_assets.py +4d7866102e8d7763c2ea9450bfca7ccda1b1c3f11d279ee788e222bb3f5df0ef tests/test_routing.py +a03ceba145c1c37a87c47f733cae785e23a530db2c9d4d9e225d6946eb49a08c tests/test_runner_generation.py 8d50232622e7b27b68d65f37fc724f50adbb411cb1c89a91bf101ccbc9213262 tests/test_scaffold.py -74186361fd5b94f60a50bd3bc769539ac26cc9660e460023e9a03ffaa1eac14d tests/test_session_launch.py +365737ca68abb7b9971b1a4a15e14f5c8d1d6849d712c7f11961148099d7a702 tests/test_session_launch.py 81c0f5e333a3530d4aae5dd489f49d6779347eb087f5b5fe1d283411c11436bd tests/test_skill_contracts.py 4ad3726c148bda364ffbe670660ab7867967e07edfeb13364af342efd3281b43 tests/test_tmux.py 1112b48c728585f7206e5359e61f813f2c65fc8a333bbfdfa21e00452e4daee9 tests/test_util.py +854440f267987b4a8cff5defd0ab3fd7075300243804cbff2cb33e12c19c6612 tests/test_workflow.py +e2854d6069fd85bd18ee760f162c31e51973f47c688d4d27a692466df8cc89d2 tests/test_workflow_receipt.py +ee7ad89390cc7e1d5f52758daa529d1c61f32c79f220ffba080ba9c796de2131 tests/test_workflow_templates.py 610f1971f5fffe724f39d7f1eb098370823f1d4a611e00cce9d0bdf00c2a897b tests/test_worktree.py c50a261eb8faf8b9508174bedf60cdfff24e3151fc7faf5db65345fe23bfb3e0 uninstall.sh diff --git a/README.md b/README.md index a3cf507..a304851 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ It provides: - durable parent/child progress, steering, acknowledgement, and blocking wait records for active runs; - multi-signal health diagnostics based on terminal, heartbeat, lifecycle, and log state; +- restart-safe workflow graphs with approvals, bounded result binding, deterministic templates, explainable routing, and aggregate receipts; +- sealed provider stream evidence and comparison-safe token/cost normalization; - deterministic evaluation collectors, scorers, ledgers, comparisons, and review receipts; - prompt-pack scaffolding, structural validation, checksums, and deterministic `.tar.zst` archives; - reusable ticket-completion and phase-gate templates; -- skills for orchestration, prompt-pack construction, delegated implementation, and independent review. +- skills for orchestration, prompt-pack construction, delegated implementation, and independent review; +- an optional bounded read-only local stdio MCP adapter. It intentionally does **not** provide automatic merging, automatic agent killing, a daemon, a web UI, remote execution, or autonomous model selection. @@ -261,6 +264,35 @@ two columns, with at most three agents per column (six total). Prompt-pack task dependencies are validated as a cross-phase DAG. Tickets may optionally declare a JSON Schema result contract; validated `result.json` artifacts and collection receipts are copied into and sealed with the durable run. See [Workflow Foundations Plan](docs/WORKFLOW_FOUNDATIONS_PLAN.md). +## Workflow graphs + +Workflows use a canonical normalized snapshot plus an append-only event journal. +They reuse the existing session launch service and never create a second executor +path. Approval gates validate immutable lifecycle receipt chains; input bindings +copy bounded JSON Pointer values from sealed ancestor results; terminal workflows +can be sealed into an aggregate receipt. + +```bash +agent-workflow workflow validate ./workflow.json +agent-workflow workflow start ./workflow-run ./workflow.json +agent-workflow workflow status ./workflow-run ./workflow.json +agent-workflow workflow resume ./workflow-run ./workflow.json +agent-workflow workflow seal ./workflow-run ./workflow.json +agent-workflow workflow verify ./workflow-run ./workflow.json +``` + +Generate one of the three authorized graph shapes with `workflow template`: + +```bash +agent-workflow workflow template pipeline ./pipeline-spec.json --output ./workflow.json +agent-workflow workflow template parallel-review-fan-in ./review-spec.json --output ./workflow.json +agent-workflow workflow template implementation-independent-review ./implementation-review-spec.json --output ./workflow.json +``` + +After `start`, later commands must supply the same snapshot digest. Workflow +status files are projections; the snapshot, event journal, child seals, approval +receipts, and aggregate workflow receipt are the evidence authorities. + ## Prompt packs ```bash @@ -283,7 +315,7 @@ agent-workflow review eval-p0-01 --actor reviewer --reason "gates checked" agent-workflow accept eval-p0-01 --actor reviewer --reason "approved" --revision SHA ``` -Baseline commands and scope are captured before the agent; post scope is captured before post commands. Collector artifacts are sealed before scoring. Evaluator-only oracle material remains outside the checkout and is addressed by ID and SHA-256. +Baseline commands and scope are captured before the agent; post scope is captured before post commands. Collector artifacts are sealed before scoring. Structured executor streams are bounded and normalized into sealed provider evidence with explicit delta/cumulative/terminal semantics. Incomplete or malformed usage evidence is rejected by trial collection, and provider-billed versus locally estimated cost remains separate. Evaluator-only oracle material remains outside the checkout and is addressed by ID and SHA-256. Inspect AI, statistics, OpenTelemetry, MLflow, and generated shell completions are optional extras. Their adapters are intentionally experimental seams: the Inspect seam reuses the public `inspect_swe` Codex and Claude agents inside an Inspect-owned Docker sandbox, while paid model trials and external backend/harness validation remain operator-run gates. @@ -297,6 +329,14 @@ Authoritative records are stored under: Each worktree receives a discoverability symlink at `.delegations/`. Deleting a worktree therefore does not delete the authoritative evidence bundle. `final-receipt.json` hashes every required artifact; `events.jsonl` and immutable review receipts record later lifecycle actions without rewriting sealed agent evidence. +## Optional MCP server + +Install the `mcp` extra and run `agent-workflow-mcp` from an MCP host that supports +local stdio servers. The current adapter exposes bounded read-only run, status, +message, and receipt resources plus pack validation. It does not expose launch, +workflow mutation, review/acceptance, raw shell, arbitrary paths, terminal +capture, or HTTP. See [MCP Server Implementation Proposal](docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md) for the separately gated mutation plan. + ## Compatibility scripts The `scripts/` directory preserves the original helper filenames as thin wrappers around the CLI. Lifecycle behavior belongs only in `src/agent_workflow/`. @@ -307,6 +347,11 @@ The `scripts/` directory preserves the original helper filenames as thin wrapper - `DELEGATION_RUNBOOK.md` - `docs/PROMPT_PACK_STANDARD.md` - `docs/ARCHITECTURE.md` +- `docs/diagrams/REPOSITORY_CHART_PACK.md` +- `docs/PROVIDER_EVIDENCE_RESEARCH.md` +- `docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md` +- `docs/MCP_TOOL_AND_RESOURCE_CATALOG.md` +- `docs/MCP_THREAT_MODEL.md` - `docs/MODEL_TIERS.md` - `docs/TEST_POLICY.md` - `docs/STALL_RECOVERY.md` @@ -317,7 +362,7 @@ The `scripts/` directory preserves the original helper filenames as thin wrapper ## Development validation ```bash -PYTHONPATH=src python3 -m unittest discover -s tests -v +PYTHONPATH=src python3 -m pytest -q python3 -m compileall -q src ./scripts/release-check.sh ``` diff --git a/REMOVED_PATHS.txt b/REMOVED_PATHS.txt index f7121c1..e665998 100644 --- a/REMOVED_PATHS.txt +++ b/REMOVED_PATHS.txt @@ -1,9 +1,11 @@ -IMPLEMENTATION.patch -IMPLEMENTATION_REPORT.md -VALIDATION.md -src/agent_workflow/__pycache__/ -src/agent_workflow/eval/__pycache__/ -src/agent_workflow/integrations/__pycache__/ -src/agent_workflow/tax_machine.py -tests/__pycache__/ -tests/test_tax_machine.py +# Removed by the 0.2.0 workflow-completion change set. +src/agent_workflow/mcp/SDK_SNAPSHOT.md +src/agent_workflow/mcp/sdk/ + +# Generated paths removed before packaging (not source changes). +.pytest_cache/ +**/__pycache__/ +**/*.pyc +build/ +dist/ +*.egg-info/ diff --git a/SESSION_RESTORE.md b/SESSION_RESTORE.md index 6f844e3..1f8b2cb 100644 --- a/SESSION_RESTORE.md +++ b/SESSION_RESTORE.md @@ -1,102 +1,54 @@ -# Session Restore — agent-workflow +# Session restore checkpoint -## Current checkpoint +**Release:** 0.2.1 +**Branch:** `feature/workflow-prep-for-mcp` +**Checkpoint date:** 2026-07-24 -Captured 2026-07-25 in `/lump/apps/agent-workflow`. +## Current implementation state -- Current branch: `feature/workflow-prep-for-mcp` -- Current HEAD: `c219eb77f2a4f9979292667e8596e2d9e4f3c08f` — workflow additions and fixes preparing groundwork for MCP server implementation. -- Version: `0.1.12`; `agent-workflow --version` reports `0.1.12`. -- `agent-workflow doctor --json`: `ok: true`; Codex and Claude executors are installed and detected. -- Latest Claude-default policy verification: `157 passed, 1 skipped`; release audit valid. -- ChatGPT completion pack: `dist/chatgpt-workflow-completion-next.tar.zst` - SHA-256: `b2a2942d91ac9a6b1e8094be7b3298e6da1a52649e5d702b74e67b1951743ce5`. -- ChatGPT source archive with `.git` history: - `dist/agent-workflow-0.1.12-chatgpt-workflow-completion-source.tar.zst` - SHA-256: `dc52535d3236411fe321321bde6da47c6f7d8ade4b493d207ba97967230005bd`. - It excludes Python environments/libraries, caches, build output, generated - index/state, and prior archives. The archive passed `zstd -t` and inventory - checks confirmed `.git/HEAD` and `.git/objects/` are present. -- Main working tree has only these untracked files: - `CHANGED_FILES.md`, `CLEANUP_AND_REMOVAL_AUDIT.md`, - `FEATURE_TEST_LEDGER.md`, and `REMOVED_PATHS.txt`. -- Do not reset, clean, or overwrite the dirty checkout. Preserve user changes. +- Workflow foundation tickets WF-00, WF-01, WF-02, WF-10, WF-11, WF-12, WF-20, WF-21, and WF-22 are implemented in the current working tree. +- Receipt-backed approval gates reconstruct canonical lifecycle evidence rather than trusting mutable status pointers. +- Result bindings use bounded RFC 6901 JSON Pointers over sealed ancestor results; parent snapshots and child `workflow-inputs.json` are atomically installed read-only before executor launch. +- Terminal workflows can be sealed and verified through aggregate workflow receipts. +- The three authorized templates and deterministic routing advisor are implemented. +- BKL-003 provider evidence and BKL-005 trial evidence are implemented with 0.2.1 fail-closed stream and scorer-receipt validation; BKL-004 real paid executor cohorts remain open. +- MCP-001 and MCP-002 remain implemented as a bounded read-only local stdio adapter. WF-22 is complete, so MCP-003 is now ready but not implemented. +- The unused vendored MCP SDK source tree has been removed. The optional pinned dependency is recorded in `src/agent_workflow/mcp/SDK_DEPENDENCY.md`. -## Verified foundation state +## Durable authorities -- Codebase-memory MCP was rebuilt before implementation and later refreshed: - 5,159 nodes, 11,357 edges, persistent artifact present at - `.codebase-memory/graph.db.zst`. -- Accepted P0 workflow chain exists in the clean integration worktree: - `/home/nate/.local/share/agent-workflow/worktrees/workflow-foundations-integration` - at `f022cf7` (`fix: bound workflow cli filesystem errors`). -- Integration worktree tests: `178 passed, 1 skipped`. -- Integration release audit: `release assets: valid`. -- Main backlog records `WF-001`, `WF-002`, `WF-00`, `WF-01`, and `WF-02` as done. -- `BKL-006` and `BKL-008` are done. `BKL-001` and `BKL-002` remain P0 ready. -- MCP-001 and MCP-002 are done; MCP-003 remains blocked on WF-22. +- Per-run evidence lives below the configured XDG state root, normally `~/.local/state/agent-workflow/runs//`. +- Workflow state is reconstructed from `workflow-snapshot.json` and `workflow-events.jsonl`. +- The canonical snapshot is read-only and descriptor-validated; scheduler mutation, sealing, verification, and projection refresh share `workflow.lock`. +- `status.json`, `workflow-status.json`, logs, and tmux capture are projections/observations. +- `final-receipt.json`, lifecycle receipts, and `workflow-receipt.json` are read-only canonical seals; final-run verification returns the digest of the same receipt bytes read under `seal.lock`, workflow receipts use one stable descriptor under `workflow.lock`, and lifecycle receipt roots may not be symlinks. +- Content-addressed scorer receipts are regular, non-symlink, read-only files; lifecycle review binds the exact score-set bytes it validated. +- Authority-bearing sealed JSON is read through beneath-root descriptors and matched to its final-receipt size/hash; read-only enforcement covers required and optional artifact trees without following symlinks. -## Feature test ledger +## Resume validation -Use [FEATURE_TEST_LEDGER.md](FEATURE_TEST_LEDGER.md) as the durable feature-by-feature verification record. It records passing features and known partials, including: +From the repository root: -- durable worktrees, model/class/no-go policy, completion contracts, workflow replay/scheduling, and codebase-memory: useful and passing; -- late steering and child progress delivery: partial because one-shot executors do not semantically consume late messages and child state writes can be read-only; -- review/accept/reject lifecycle: partial because ordinary runs currently require a score set; -- executor command safety: partial because narrowly scoped temporary cleanup was rejected by the safety filter; -- linked-worktree delegated commit/finalization: partial because the WF-10 child could not write its Git index lock. - -## WF-10 status - -WF-10 was implemented in an isolated worktree and committed coordinator-side as: - -- `a512bb26f296c6311c8ac60e58bcec6b6644ef88` — `feat: add receipt-backed approval gates`. -- Focused approval/lifecycle/ledger tests passed: `9 passed`. -- The implementation agent reported full-suite and release-audit success, but could not finalize its completion sidecar because the linked worktree Git index was read-only. -- Independent Luna review ran against the commit and rejected it. Full review gates passed (`181 passed, 1 skipped`; release audit valid), but approval trusted the mutable `status.json` receipt path and did not prove that the receipt was the append-only artifact for the run. A copied/forged same-session receipt could therefore satisfy approval. -- WF-10 remains `ready` in `BACKLOG.md`; it is not accepted or integrated. -- Correction worktree is preserved at: - `/home/nate/.local/share/agent-workflow/worktrees/workflow-foundations-wf-10-correction` - based on `a512bb2`. No correction agent was launched after the stop request. -- Review and implementation sessions were terminated. Their sealed evidence remains under `/home/nate/.local/state/agent-workflow/runs/`. - -## Delegation session records - -Most historical sessions have detached tmux records. The latest relevant states are: - -- `wf02-mini-r3-20260724`: completed, valid handoff, exit 0. -- `wf02-review-luna-r3-20260725`: completed, valid handoff, exit 0. -- `wf10-mini-20260725`: failed during finalization, exit 1, completion missing; source evidence preserved. -- `wf10-review-luna-20260725`: review result valid but session terminated after rejection, exit 1; sealed evidence preserved. - -No agent should be treated as active solely because an old detached tmux session is listed. Confirm with `agent-workflow --json status ` and the `tmux_alive`/`observed_state` fields. - -## Next work, in order - -1. Correct WF-10 receipt authenticity/append-only provenance; add a regression test for copied or forged same-session receipts. -2. Run focused tests, full pytest, release audit, and an independent review. Integrate only after a valid accepted handoff. -3. Continue WF-11 result binding, then WF-12 aggregate workflow receipts, using isolated worktrees and independent review for each ticket. -4. Address P0 BKL-001 durable consumer cursors and BKL-002 executor-specific late steering according to the canonical backlog ordering. -5. Rebuild the codebase-memory index after accepted integrations. -6. Only after the requested implementation set is accepted: bump/reconcile version, build, test, install globally, and verify `agent-workflow doctor --json` and `agent-workflow --version`. - -## Required operating rules - -- Read `BACKLOG.md` as canonical task state. -- Use `agent-workflow` CLI launches with isolated worktrees for delegated work; do not use native subagent spawning as a substitute for durable runs. -- Follow Mini → Luna → Terra routing without skipping rungs; no-go models require explicit authorization. -- Require valid `agent-workflow/completion/v1` handoffs, exact command evidence, scope checks, manifest validation, and independent review before integration. -- Never infer semantic steering delivery from logs, terminal capture, tmux state, or keystrokes. -- Claude launches default to interactive, including exploratory/review classes; `--no-interactive` and `--structured` are explicit opt-outs. Codex class defaults remain unchanged. -- Preserve dirty user changes and use `apply_patch` for repository edits. +```bash +PYTHONPATH=src python3 -m pytest -q +python3 -m compileall -q src +bash -n install.sh uninstall.sh scripts/*.sh +python3 scripts/audit-release-assets.py +PYTHONPATH=src python3 -m agent_workflow.cli pack validate prompt-packs/chatgpt-workflow-completion-next +``` -## Useful verification commands +Regenerate the release manifest after any tracked content change: ```bash -cd /lump/apps/agent-workflow -agent-workflow doctor --json -agent-workflow --version -agent-workflow --json status --capture 0 -PYTHONPATH=src pytest -q -python3 scripts/audit-release-assets.py +python3 scripts/audit-release-assets.py --write-manifest ``` + +## Primary references + +- `BACKLOG.md` +- `FEATURE_TEST_LEDGER.md` +- `docs/ARCHITECTURE.md` +- `docs/diagrams/REPOSITORY_CHART_PACK.md` +- `docs/PROVIDER_EVIDENCE_RESEARCH.md` +- `docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md` +- `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md` diff --git a/VERSION b/VERSION index 0e24a92..0c62199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.12 +0.2.1 diff --git a/agent-workflow.yaml b/agent-workflow.yaml index 57c0cec..79455db 100644 --- a/agent-workflow.yaml +++ b/agent-workflow.yaml @@ -1,6 +1,6 @@ schema: agent-workflow/repository/v1 name: agent-workflow -version: 0.1.12 +version: 0.2.1 terminal_backend: tmux python: ">=3.11" state_model: xdg-json-files diff --git a/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md b/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md index a8e38a5..d072ed3 100644 --- a/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md +++ b/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md @@ -1,5 +1,11 @@ # P0: Agent-Workflow Skill and Orchestrator Integration +**Status:** Implemented. This document preserves the original P0 acceptance contract; current behavior is summarized below and tracked in `BACKLOG.md`. + +## Current outcome + +The repository now installs and cross-links the orchestration, delegated-implementation, phase-gate-review, and prompt-pack-builder skills across the supported shared, Codex, and Claude discovery roots. Those skills name the canonical CLI lifecycle, preserve the distinction between host-native subagents and durable `agent-workflow` runs, and document the tmux current-window/detached fallback behavior. Installer ownership checks remain fail-closed for unrelated paths. + ## Problem `agent-workflow` has a complete CLI, durable run evidence, lifecycle runbooks, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f3f66ac..a08adf1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,92 +1,150 @@ # Architecture +`agent-workflow` is a local, terminal-first orchestration and evidence system. The repository owns durable execution semantics; prompt packs own project-specific work decomposition; target repositories own implementation source. + +The complete visual inventory is in [Repository Chart Pack](diagrams/REPOSITORY_CHART_PACK.md). + +## Architectural principles + +1. Durable JSON/JSONL records and immutable receipts are authoritative. +2. `status.json`, rendered ledgers, tmux capture, and logs are projections or observations. +3. The CLI, workflow scheduler, and MCP adapter use shared services; there is no alternate executor path. +4. Every delegated ticket receives isolated Git source state and a durable run directory. +5. Approval is a separate evidence-backed lifecycle dimension, never implied by process success. +6. Provider usage and cost are normalized only from bounded raw evidence with explicit semantics. +7. Networked/multi-host behavior is deferred until a measured need and explicit authorization exist. + ## Ownership model ```text agent-workflow repository - owns execution, terminal sessions, state, templates, validation, and workflow policy + execution, tmux, durable state, schemas, workflow policy, receipts, evaluation prompt pack - owns project phases, tickets, references, code outlines, and acceptance gates + project phases, dependency graph, tickets, references, result contracts, gates target repository/worktree - owns implementation source and legitimate project tests + implementation source, project-native tests, legitimate generated artifacts ``` +## Major components + +| Area | Modules | Responsibility | +|---|---|---| +| CLI/config | `cli.py`, `config.py`, `doctor.py` | live parser, configuration, local capability checks | +| Sessions/processes | `sessions.py`, `runner.py`, `executors.py`, `tmux.py`, `process.py` | canonical launch, process ownership, structured streams, retry/recovery | +| Durable state | `state.py`, `events.py`, `messages.py`, `ledger.py` | status projections, append-only lifecycle/control records, ledgers | +| Evidence | `receipts.py`, `metrics.py`, `provider_evidence.py`, `lifecycle.py` | final seals, metrics, provider usage, review/accept/reject receipts | +| Workflows | `workflow.py`, `scheduler.py`, `workflow_service.py`, `approval.py`, `bindings.py`, `workflow_receipt.py`, `workflow_templates.py`, `routing.py` | graph validation/replay, scheduling, approvals, result binding, aggregate seals, templates, advice | +| Prompt packs | `pack.py`, `manifests.py`, `native_jobs.py`, `contracts.py` | scaffold, DAG validation, checksums/archive, structured results | +| Evaluation | `evaluation.py`, `eval/*`, `inspect_adapter.py`, `integrations/*` | collectors, scoring, immutable trials, cohort comparison, optional adapters | +| MCP | `mcp/server.py`, `mcp/services.py` | optional bounded read-only stdio adapter over shared read services | + ## Runtime state -The authoritative state root is XDG-based: +The default XDG state root is: ```text ~/.local/state/agent-workflow/ └── runs/ └── / - ├── status.json + ├── status.json # mutable projection ├── source-baseline.json ├── prompt.md ├── launch-prompt.md ├── command.json ├── run-provenance.json - ├── executor-events.jsonl + ├── executor-events.jsonl # bounded raw stream evidence ├── executor-stderr.log ├── output.log ├── completion.md ├── completion.json + ├── result.json # optional structured task result + ├── provider-evidence.json # derived and sealed + ├── execution-metrics.json + ├── control-events.jsonl + ├── assignments.jsonl ├── patch.diff ├── final-status.json - ├── final-receipt.json - ├── heartbeat.json - ├── events.jsonl - ├── collections/ # evaluation runs only - ├── scope/ # evaluation runs only - ├── receipts/ # review lifecycle only - ├── run.sh - └── scores/ # generated after sealing + ├── final-receipt.json # canonical read-only run seal + ├── collections/ + ├── scope/ + ├── scores/ + └── receipts/ # canonical read-only lifecycle chain ``` -A worktree-local `.delegations/` symlink points to the durable run directory. The tool adds `.delegations/` to Git's local exclude metadata, so observability does not create a source change. Worktree cleanup therefore does not destroy evidence. +A worktree-local `.delegations/` symlink points to the durable run directory. Git local exclude metadata hides `.delegations/`, and deleting the worktree does not delete evidence. -## Process boundary +## Session execution boundary -The CLI creates one detached tmux session whose process is a generated runner. The runner: +The session service resolves agent identity, class, executor, model, permissions, structured/interactivity mode, pane capacity, and no-go authorization. It writes initial contracts, then invokes the generated Python runner through tmux. The runner: -1. atomically marks the run `running` and emits a lifecycle event; -2. starts the executor as its own process group and forwards interrupts; -3. passes `launch-prompt.md` on stdin and preserves stdout JSONL plus stderr separately; -4. writes heartbeat records and enforces evaluation time/token budgets; -5. captures post-agent scope before running post-agent acceptance commands; -6. finalizes provenance, captures the patch, validates every core contract, and seals the fixed artifact set; -7. records the final-receipt hash in mutable status and makes sealed evidence read-only. +1. records the execution transition; +2. starts the executor as a process group and forwards interrupts; +3. preserves stdout JSONL/text and stderr separately with capture bounds; +4. emits heartbeat/control/evaluation evidence; +5. collects completion, structured result, commands, scope, patch, and metrics; +6. derives provider evidence before sealing; +7. writes `final-status.json`, validates contracts, and under `seal.lock` atomically installs a read-only `final-receipt.json`; verification reads the receipt and sealed artifacts through stable beneath-root descriptors that reject symlinks in every path component. -The generated `run.sh` is a thin, shell-quoted invocation of the Python runner and is checked with `bash -n`. Process ownership, stream parsing, collection, and sealing exist only in reusable Python modules. +Retries create a new run ID and preserve `retry_of` lineage. They do not overwrite prior evidence. -## State model +## Durable messages and wakeups -```text -prepared -> launched -> running -> completed - \-> failed - \-> interruption_requested -> interrupted -operator kill -------------------------------------------> killed -``` +Control/progress/acknowledgement records are appended and fsynced. Consumers replay by sequence/cursor. `tmux wait-for` is only a local wakeup accelerator; a missed or coalesced signal cannot lose a record. A steer remains pending until a correlated acknowledgement or executor-specific delivery record proves a stronger state. -Review is an independent dimension: `null -> reviewed -> accepted|rejected`. Review receipts reference immutable final-receipt and deterministic score-set hashes; execution success never implies acceptance. +## Workflow model -`possibly_stalled`, `orphaned`, and `terminal_unavailable` are observed states, not durable lifecycle states. +A normalized workflow snapshot is immutable input. `workflow-events.jsonl` is the authoritative append-only state transition and node-binding journal. `workflow-status.json` and `workflow-run.json` are rebuilt projections. -## Security posture +The scheduler: + +- replays and validates contiguous event sequence and snapshot digest; +- reconciles running nodes from matching child provenance or sealed terminal evidence; +- requires a durable child footprint before recording `running`; +- propagates dependency failures and reopens only dependency-failed descendants during prerequisite retry; +- enforces bounded parallelism across both existing running nodes and new launches; +- verifies approval nodes from canonical lifecycle receipt chains; +- resolves result bindings only from completed ancestor runs with sealed `result.json` evidence; +- copies bounded values into parent binding snapshots and child `workflow-inputs.json`, both installed read-only before executor launch, and binds their digest into child provenance; +- computes routing advice but delegates actual selection to existing class/executor/model policy; +- launches through the canonical session service; +- records retry lineage and terminal reasons. -- Session IDs are restricted to safe filesystem/tmux characters. -- Status writes are atomic and lifecycle transitions are append/fsync-before-snapshot events. -- Prompt SHA-256 and source revision are recorded before execution. -- The workflow stores no external-service credentials. -- No automatic merge, branch deletion, or failed-worktree cleanup occurs. -- Interrupt, terminate, and kill preserve logs and source trees. +Only three graph templates are authorized: pipeline, bounded parallel review with fan-in, and implementation followed by independent review. -## Evaluation topology +A terminal workflow can be sealed into `workflow-receipt.json`, which is atomically installed read-only under `workflow.lock` and commits to the snapshot, event journal, exact node set and states, retry/binding history, child final receipts, completions, input binding digests, approval digests, and terminal disposition. Verification reads, mode-checks, and hashes the receipt from one non-symlink descriptor, rebuilds it from durable evidence under the same workflow lock, and compares exactly. + +## Approval authority + +Execution state and review disposition are separate. Creating a lifecycle receipt derives completion state, task tier, executor identity, and evaluation requirements from receipt-listed terminal artifacts read through beneath-root descriptors rather than mutable `status.json`; the status file is updated only as a projection after the receipt append. Canonical approval reconstruction requires `receipts/` itself to be a real directory, then scans only contiguous, read-only, regular files through stable descriptors. Receipt creation fsyncs the directory entry. Each receipt must match its filename/action/session and the canonical final-receipt digest. Acceptance additionally checks the exact completion revision, score stability, valid collected completion, task tier, and reviewer independence where required. + +## Provider and evaluation evidence + +Raw executor events must be stable regular non-symlink files, are capped at 16 MiB for parsing, and are fully hashed from one file descriptor. Provider adapters classify only known boundaries as `delta`, `cumulative`, or `terminal`. Terminal totals are not added to deltas; conflicting or empty terminal updates, nonmonotonic cumulative totals, non-finite values, malformed/truncated streams, mixed nonterminal modes, incomplete cost metadata, and unidentified duplicate deltas are incomplete. Cached and reasoning tokens remain details. + +`provider-evidence.json` is sealed with the run. Trial extraction verifies the final seal and uses the digest of the exact receipt bytes verified under `seal.lock`, checks complete provider evidence, and validates the score set against regular read-only content-addressed scorer receipts bound to the same final receipt. Lifecycle review hashes the exact score-set bytes it validated. Provider-billed and locally estimated cost remain separate; currency and price-catalog compatibility determine whether cost comparisons are valid. + +## MCP boundary + +The current MCP server is optional, local stdio, and read-only except for pack validation. It uses the public pinned `mcp` package and bounded shared read services. It exposes no launch, workflow mutation, lifecycle mutation, raw shell, tmux, arbitrary paths, terminal capture, or HTTP transport. + +The planned mutation design is in [MCP Server Implementation Proposal](MCP_SERVER_IMPLEMENTATION_PROPOSAL.md). Future tools must add durable idempotency and call existing services. Streamable HTTP requires a separate authorization ADR. + +## Security posture -The accepted topology is documented in `docs/adr/0001-inspect-evaluation-topology.md`: host `agent-workflow` owns tmux and local evidence, while Inspect owns the Docker sandbox, model bridge, transcripts, and adapter lifecycle. Public `inspect_swe.codex_cli()` and `inspect_swe.claude_code()` adapters are reused whole; private Inspect internals are not copied. +- schema, ID, type, size, and path validation at every external boundary; +- configured-root containment after resolution and symlink rejection; +- atomic writes and append/fsync-before-projection event ordering; +- canonical receipts are regular, non-symlink, read-only files; sealed authority inputs are reopened only through receipt-matched beneath-root descriptors; +- prompt/source/config/command/artifact SHA-256 provenance; +- no automatic merge, branch deletion, failed-worktree cleanup, remote execution, or autonomous model selection; +- no-go models require explicit recorded authorization; +- external evaluator/oracle material stays outside delegated worktrees; +- logs and terminal capture never grant approval or prove message delivery. +See [Security Trust Boundaries](SECURITY_TRUST_BOUNDARIES.md) and [MCP Threat Model](MCP_THREAT_MODEL.md). -## Prompt-pack dependency and result contracts +## Release and packaging -Task dependencies are validated globally before execution, including unknown references, self-dependencies, and cycles. A task may declare a pack-local JSON Schema for a structured result. The executor writes through the existing handoff boundary; the runner performs bounded non-symlink reads, validates the result, writes a collection receipt, and seals accepted evidence. This is a contract layer only; scheduling remains separate and must invoke the existing launch service. +Release assets are checked by `scripts/audit-release-assets.py`; `MANIFEST.sha256` must cover every distributable file. The release gate runs tests, compile checks, shell syntax checks, prompt-pack validation, manifest verification, and deterministic archive tooling checks. Third-party MCP SDK source is not vendored or packaged; only the pinned optional dependency and dependency record remain. diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 1d0eb8c..f800c8c 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -1,80 +1,127 @@ -# Command Reference +# Command reference + +The live parser is authoritative. Use `agent-workflow COMMAND --help` for exact installed options. Global `--json` and `--config PATH` may appear before or after the subcommand; tokens after launch `--` belong to the delegated command. + +## Top-level ```text +agent-workflow [--config PATH] [--json] COMMAND ... agent-workflow --version agent-workflow doctor agent-workflow config show +agent-workflow completion bash|zsh|tcsh +``` + +`doctor` is offline. It probes only local binaries and local `--version`/`--help` surfaces. + +## Worktrees and sessions + +```text agent-workflow worktree create REPO TICKET BASE [--dest PATH] [--branch NAME] [--allow-dirty] agent-workflow worktree list REPO agent-workflow worktree remove REPO WORKTREE [--delete-branch] [--force] -agent-workflow launch SESSION WORKDIR PROMPT [--agent-name NAME] [--agent-class CLASS] [--executor NAME] [--model MODEL] [--structured|--interactive|--no-interactive] [--allow-no-go-model] [--pane-limit-action prompt|close-idle|non-interactive|cancel] [--evaluation PLAN] [--tier low|medium|high|critical] [--allow-dirty] + +agent-workflow launch SESSION WORKDIR PROMPT + [--ticket ID] [--tier low|medium|high|critical] [--pack PACK] [--job JOB] + [--agent-name NAME] [--agent-class CLASS] [--executor NAME] [--model MODEL] + [--allow-no-go-model] [--evaluation PLAN] [--structured] + [--interactive|--no-interactive] [--allow-dirty] + [--pane-limit-action prompt|close-idle|non-interactive|cancel] + agent-workflow launch SESSION WORKDIR PROMPT -- COMMAND... agent-workflow list agent-workflow status SESSION [--capture N] agent-workflow attach SESSION agent-workflow tail SESSION [--lines N] +agent-workflow interrupt SESSION +agent-workflow terminate SESSION [--grace-seconds N] +agent-workflow kill SESSION +agent-workflow restart SESSION [--new-session NAME] +``` + +Configured launches enforce class/executor/model allowlists and permission arguments. A no-go model requires `--allow-no-go-model`, which is recorded. `--structured` and native interactive TUI mode are mutually exclusive. Git worktrees must be clean unless `--allow-dirty` is explicit; retries preserve prior evidence and lineage. + +## Durable messages + +```text agent-workflow steer SESSION TEXT --actor ID agent-workflow progress SESSION TEXT --actor ID agent-workflow ack SESSION MESSAGE_UUID TEXT --actor ID +agent-workflow watch SESSION [--after SEQUENCE] [--timeout SECONDS] +``` + +These commands append validated fsynced records. `watch` replays the journal and may use a best-effort tmux wakeup hint. A steer is pending until correlated evidence proves acknowledgement/application. + +## Interactive agent context and reuse + +```text agent-workflow agent context SESSION agent-workflow agent task-complete SESSION --actor ID --summary TEXT [--tag TAG] [--file PATH] agent-workflow agent candidates WORKDIR [--ticket ID] [--pack ID] [--retry-of ID] [--agent-class CLASS] [--tag TAG] agent-workflow agent reuse SESSION PROMPT --actor ID [--ticket ID] [--pack ID] [--retry-of ID] [--tag TAG] agent-workflow agent auto-reuse WORKDIR PROMPT --actor ID [--ticket ID] [--pack ID] [--retry-of ID] [--agent-class CLASS] [--tag TAG] -agent-workflow watch SESSION [--after SEQUENCE] [--timeout SECONDS] -agent-workflow interrupt SESSION -agent-workflow terminate SESSION [--grace-seconds N] -agent-workflow kill SESSION -agent-workflow restart SESSION [--new-session NAME] +``` + +`task-complete` is the only `busy -> idle_reusable` transition. Reuse is restricted to the same worktree; automatic reuse requires exact ticket/retry lineage and remains pending until acknowledgement. + +## Workflow graphs + +```text +agent-workflow workflow validate SNAPSHOT +agent-workflow workflow template pipeline SPEC.json --output SNAPSHOT.json +agent-workflow workflow template parallel-review-fan-in SPEC.json --output SNAPSHOT.json +agent-workflow workflow template implementation-independent-review SPEC.json --output SNAPSHOT.json +agent-workflow workflow start RUN_DIR SNAPSHOT +agent-workflow workflow status RUN_DIR SNAPSHOT +agent-workflow workflow resume RUN_DIR SNAPSHOT +agent-workflow workflow seal RUN_DIR SNAPSHOT +agent-workflow workflow verify RUN_DIR SNAPSHOT +``` + +`start` stores the canonical normalized snapshot and schedules eligible nodes. Later status/resume/seal/verify calls must supply the same snapshot digest; a substituted snapshot is rejected. Status is reconstructed from append-only events. Approval nodes verify canonical lifecycle receipts. Result bindings use bounded RFC 6901 JSON Pointers over sealed ancestor results. `seal` requires every node to be terminal and writes a read-only aggregate receipt. + +Template request files contain `workflow_id`, `pack_id`, `pack_manifest_sha256`, and template-specific `parameters`. Expansion is deterministic and produces the same canonical snapshot schema used by hand-authored workflows. + +## Review and acceptance + +```text agent-workflow review SESSION --actor ID --reason TEXT agent-workflow accept SESSION --actor ID --reason TEXT --revision SHA agent-workflow reject SESSION --actor ID --reason TEXT -agent-workflow ledger PACK [--runs-root PATH] [--output PATH] +``` + +Review/accept/reject append immutable lifecycle receipts. Acceptance requires a prior review, valid final seal and collected completion, exact head revision, stable passing scores when evaluation is required, and reviewer independence for high/critical tiers. + +## Evaluation + +```text agent-workflow eval validate PLAN [--pack PACK] agent-workflow eval score RUN [--output-dir PATH] [--oracle-root PATH] agent-workflow eval report RUN [--format json|markdown] [--output PATH] +agent-workflow eval collect RUN... --output TRIALS.json +agent-workflow eval compare BASELINE.json CANDIDATE.json [--output PATH] agent-workflow eval inspect PROMPT --executor codex|claude --model MODEL --dockerfile FILE --log-dir DIR agent-workflow eval swebench-prediction RUN --instance-id ID --model MODEL --output FILE.jsonl -agent-workflow completion bash|zsh|tcsh +agent-workflow ledger PACK [--runs-root PATH] [--output PATH] +``` + +`eval collect` accepts only sealed runs with complete provider evidence. Provider-billed and locally estimated cost remain separate. Cost comparisons are omitted when currency or local price-catalog identity differs. + +## Prompt packs + +```text agent-workflow pack scaffold DEST [--phases N] [--name NAME] agent-workflow pack validate SOURCE [--skip-checksums] agent-workflow pack checksum SOURCE agent-workflow pack archive SOURCE OUTPUT.tar.zst ``` -Use global `--json` or `--config PATH` before or after the subcommand. Options after an explicit launch `--` belong to the delegated command. - -`doctor` is offline: executor capability probes invoke only local `--version` and `--help` commands. It performs no authentication probe or model call. - -`steer`, `progress`, and `ack` append validated, fsync'd records to the active -run. `watch` blocks until a new record is durable (or its optional timeout). -They do not claim an arbitrary one-shot executor received a late prompt: a -steer becomes applied only when a child writes a correlated acknowledgement. -Interactive panes accept native terminal input, but durable steering is not -automatically injected as keystrokes. Codex configuration uses Codex nouns -such as `--sandbox` and, for the interactive TUI only, -`--ask-for-approval`; `codex exec` does not accept that approval flag. Claude -configuration uses Claude nouns such as `--permission-mode`, `--allowedTools`, -and `--disallowedTools`. Configure command-mode differences with -`interactive_permission_args` and `non_interactive_permission_args`. -Configured model allowlists are executor-specific, and no-go models require -the explicit, provenance-recorded `--allow-no-go-model` launch permission. -Claude launches default to interactive, including exploratory and review -classes; `--no-interactive` or `--structured` is the explicit opt-out. - -`agent task-complete` is the only transition from `busy` to -`idle_reusable`. `agent reuse` records a pending assignment and steer; -correlated `ack` moves it to `busy`. `agent auto-reuse` selects only exact -ticket/retry lineage and returns `action: launch` when none exists. Candidate -ranking never permits cross-worktree reuse. - -`[terminal].max_interactive_agent_width` defaults to `2` and -`max_interactive_agent_vertical` defaults to `3`, producing a six-agent grid. -Agent columns are created horizontally first, then vertical slots are balanced -across those columns. Capacity is checked before pane creation and rechecked -while splitting. Agent names are globally unique -across interactive and detached active runs; a retry receives another available -name when its original run is still active. -At the pane cap, the default attached-CLI behavior prompts to close explicitly -idle panes, launch detached/non-interactive, or cancel. Non-TTY and JSON callers -fail closed unless an action is supplied; busy panes are never auto-closed. +Validation checks required files, manifests, cross-phase dependency DAGs, result contracts, paths, checksums, and bounded portable assets. Archive creation is deterministic when GNU tar and zstd are available. + +## MCP entry point + +```text +agent-workflow-mcp [--config PATH] [--repo-root PATH] +``` + +The optional current server is local stdio and bounded/read-only, with `pack_validate` and run/status/message/receipt resources. It is a separate executable, not a top-level `agent-workflow mcp` command. Install with the `mcp` optional extra. `--repo-root` constrains pack validation to an explicit repository root; the configured state root still governs run resources. Planned mutation tools are documented but not implemented. diff --git a/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md b/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md index 020a638..bd0b329 100644 --- a/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md +++ b/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md @@ -4,18 +4,18 @@ work is tracked only in [BACKLOG.md](../BACKLOG.md); this document preserves the implementation contract and acceptance history. -## Purpose +## Historical purpose and current outcome -Complete the two remaining foundations for observable, restart-safe orchestration: +This document preserves the preimplementation contract that completed two formerly missing foundations for observable, restart-safe orchestration: 1. accelerate durable control-record delivery when parent and child share a tmux server, without making tmux authoritative; and 2. turn sealed execution metrics into comparable, provenance-backed token, cost, and timing evidence. -This plan deliberately separates implementation that is deterministic and safe to ship now from provider-specific and real-executor work that needs a controlled cohort. +The deterministic implementation is complete: tmux wakeups remain best-effort accelerators over replayable JSONL, bounded raw provider streams are sealed before normalization, provider adapters preserve explicit usage modes, and trial evidence can be persisted and compared. BKL-003 and BKL-005 are complete. The real-executor cohort remains the separately controlled BKL-004 backlog item. -## Starting point +## Historical starting point -The existing append-only `messages.jsonl` protocol is authoritative. Records are locked, flushed, and fsynced; replay is restart-safe; steering, progress, and acknowledgement records already exist. `watch` currently obtains responsiveness by replay polling. Sealed execution metrics already contain nullable normalized usage fields, elapsed time, and command-collection files, but the runner preserves only the latest executor usage event and the evaluation CLI cannot yet persist or compare a sealed-trial cohort. +At the time this plan was written, the append-only `messages.jsonl` protocol was already authoritative and restart-safe, while `watch` relied only on replay polling. Execution metrics contained nullable normalized usage fields and elapsed time, but the runner retained only the latest executor usage event and the evaluation CLI could not persist or compare a sealed-trial cohort. This paragraph is historical context, not the current implementation state. ## Non-negotiable contracts diff --git a/docs/MCP_IMPLEMENTATION_CHECKLIST.md b/docs/MCP_IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..35fba4a --- /dev/null +++ b/docs/MCP_IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,36 @@ +# MCP implementation checklist + +## MCP-003 safe mutation gate + +- [ ] Define versioned request/result/event schemas. +- [ ] Add append-only fsynced request journal and replay projection. +- [ ] Reserve idempotency keys before external effects. +- [ ] Reconcile accepted-but-nonterminal operations after restart. +- [ ] Extract only the minimum CLI logic needed into shared services. +- [ ] Add `worktree_create` without arbitrary paths. +- [ ] Add `run_launch` through canonical session launch and policy selection. +- [ ] Add workflow validate/start/status/resume/seal/verify through `WorkflowService`. +- [ ] Add progress/ack/steer through durable message APIs. +- [ ] Preserve `pending` steering semantics. +- [ ] Add request resource with bounded status/result evidence. +- [ ] Add CLI/MCP equivalence tests. +- [ ] Add traversal, symlink, redaction, bounds, conflict, crash, and replay tests. +- [ ] Run MCP Inspector and publish capability matrix. +- [ ] Update README, command reference, man page, skills, backlog, changelog, and release manifest. + +## MCP-004 destructive/review gate + +- [ ] Obtain explicit authorization after MCP-003 evidence review. +- [ ] Add per-action feature flags. +- [ ] Add interrupt/terminate only; keep force kill excluded unless separately approved. +- [ ] Add review/accept/reject through lifecycle service. +- [ ] Require actor, reason, exact revision, score/final-receipt validity, and reviewer independence. +- [ ] Add duplicate-transition and stale/forged evidence tests. +- [ ] Repeat Inspector, host, security, full-suite, and release gates. + +## MCP-005 HTTP decision gate + +- [ ] Record actual adoption/multi-process need. +- [ ] Write authorization/deployment ADR. +- [ ] Decide tenant model, IdP, resource/audience validation, registration, Origin, TLS, proxies, session persistence, rate limits, and audit retention. +- [ ] Approve or reject implementation before code exists. diff --git a/docs/MCP_SERVER_DECISION.md b/docs/MCP_SERVER_DECISION.md index 2614022..6763cfd 100644 --- a/docs/MCP_SERVER_DECISION.md +++ b/docs/MCP_SERVER_DECISION.md @@ -1,6 +1,6 @@ # MCP Server Architecture Decision -**Status:** Read-only local-stdio implementation complete; safe mutation deferred until workflow foundations complete; no HTTP rollout +**Status:** Read-only local-stdio implementation complete; safe mutation is ready for separately authorized MCP-003 work; no HTTP rollout **Decision date:** 2026-07-24 **Confidence:** High for local stdio; medium for later HTTP deployment @@ -8,7 +8,7 @@ Adopt MCP as an optional integration layer for `agent-workflow`, beginning with a thin, local, stdio-only Python server that calls existing domain services and reads authoritative run artifacts. Do not make MCP the lifecycle authority, do not expose tmux directly, and do not add a daemon or remote HTTP endpoint in the first phase. -The completed first surface is intentionally small: bounded read-only resources for run state/evidence plus `pack_validate`. Remaining mutation tools must wait until the workflow foundation is complete through `WF-22`, then reuse the same workflow, launch, routing, messaging, approval, and receipt services as the CLI. A steering call returns `pending` unless an executor-specific correlated acknowledgement proves delivery or application. +The completed first surface is intentionally small: bounded read-only resources for run state/evidence plus `pack_validate`. The workflow foundation is complete through `WF-22`; the remaining mutation tools are authorized only as the separately scoped MCP-003 backlog item and must reuse the same workflow, launch, routing, messaging, approval, and receipt services as the CLI. A steering call returns `pending` unless an executor-specific correlated acknowledgement proves delivery or application. Rejected alternatives: @@ -129,7 +129,7 @@ Add `mcp==1.28.1` as an optional extra. Implement server initialization, capabil ### Workflow prerequisite gate -Complete `workflow-foundations-next` through `WF-22` before any remaining MCP mutation work. This includes restart-safe scheduling, approvals, result binding, aggregate receipts, the three authorized templates, deterministic routing explanations, and integration review. The gate prevents MCP from becoming a second workflow implementation. +`WF-22` was completed in release 0.2.0 and its scheduler, replay, receipt, and evidence authority boundaries were hardened in release 0.2.1. Restart-safe scheduling, approvals, result binding, aggregate receipts, the three authorized templates, deterministic routing explanations, and integration review are implemented. MCP-003 may proceed as a separately scoped task, but MCP must remain a façade over these services rather than becoming a second workflow implementation. ### MCP-2 — Safe creation, workflow, and control tools @@ -166,20 +166,20 @@ stable tag `v1.28.1` (`777b8d06710c140e3606b0d4598e2aa48546c266`). The SDK's pub FastMCP API supports `@mcp.resource`, `@mcp.tool`, and `mcp.run(transport="stdio")`. The repository's `main` line is v2 alpha/beta and is not the selected dependency. -The initial scaffold is in `src/agent_workflow/mcp/server.py`. It exposes bounded +The implemented read-only adapter is in `src/agent_workflow/mcp/server.py`. It exposes bounded run/status/messages/receipt resources and `pack_validate`, while delegating to existing state, message, receipt, and manifest services. It does not expose shell, tmux, arbitrary paths, launch/control mutation, raw terminal capture, or HTTP. -The full SDK source snapshot is retained at `src/agent_workflow/mcp/sdk/` for -research and prompt-pack evidence only; the runtime package depends on the pinned -optional distribution. +The repository no longer vendors an SDK source tree. Runtime and tests use the +pinned optional `mcp==1.28.1` distribution through public APIs; dependency +provenance is recorded in `src/agent_workflow/mcp/SDK_DEPENDENCY.md`. -## Applied backlog update +## Current backlog status - **MCP-001 (done):** Reusable domain seams and typed MCP request/result contracts. - **MCP-002 (done):** Read-only local stdio MCP server with official Python SDK, bounded resources, traversal/redaction controls, and conformance tests. -- **MCP-003 (blocked on WF-22):** Safe single-run/workflow mutation tools with idempotency and durable evidence mapping. +- **MCP-003 (ready):** Safe single-run/workflow mutation tools with idempotency and durable evidence mapping; not implemented through 0.2.1. - **MCP-004 (deferred):** Gated destructive/review tools and representative-host evaluation matrix. - **MCP-005 (decision):** Authorize or reject Streamable HTTP after local adoption and security evidence. diff --git a/docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md b/docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md new file mode 100644 index 0000000..aa6c579 --- /dev/null +++ b/docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md @@ -0,0 +1,339 @@ +# MCP server implementation proposal and plan + +**Target:** post-0.2.1 mutation surface +**Current implementation:** optional, local, read-only stdio server +**Backlog authority:** `MCP-003`, `MCP-004`, and `MCP-005` in `BACKLOG.md` + +## Executive proposal + +Extend the existing MCP adapter only as a transport façade over the same application services used by the CLI. Do not create MCP-specific launch, workflow, approval, receipt, path, or message semantics. The first implementation increment (`MCP-003`) should expose safe creation and non-destructive control tools over local stdio, with idempotency records and durable result identifiers. Destructive/review actions remain a separately gated increment (`MCP-004`). Streamable HTTP remains a decision only (`MCP-005`) and requires its own authorization ADR. + +The target protocol remains MCP `2025-11-25` with the pinned optional Python SDK `mcp==1.28.1`. The SDK is not vendored; public APIs are the dependency boundary. + +## Current state + +The repository already provides: + +- `agent-workflow-mcp` stdio entry point; +- `FastMCP` resources for bounded run status, messages, and receipts; +- `pack_validate` as a read-mostly validation tool; +- configured-root containment, traversal rejection, redaction, pagination, and bounded responses; +- reusable CLI/domain services for worktrees, sessions, messages, lifecycle, workflow scheduling, result binding, approvals, and receipts; +- immutable child and aggregate workflow evidence; +- no raw shell, tmux, arbitrary path, raw terminal capture, HTTP transport, or mutation shortcut. + +## Non-negotiable invariants + +1. JSONL journals and immutable receipts remain authoritative; MCP responses are projections. +2. MCP and CLI invoke shared service functions. There is one launch path and one scheduler. +3. Paths are logical identifiers or configured-root-relative paths, never arbitrary host paths. +4. Every mutating request has an idempotency key and a durable request/result record. +5. A returned `pending` steer is not proof of delivery or application. +6. Approval tools consume and create canonical lifecycle receipts; mutable status never grants approval. +7. No-go model and executor/class policy is enforced by the existing selection path. +8. Local stdio is single-user process trust. Any network transport requires authentication, audience validation, origin checks, rate limits, and an authorization policy. + +## Target architecture + +```mermaid +flowchart LR + Host[MCP host/client] -->|stdio JSON-RPC| Adapter[FastMCP adapter] + Adapter --> Validate[Typed request validation] + Validate --> Authz[Capability and configured-root policy] + Authz --> Idem[Idempotency service] + Idem --> Facade[Shared application service façade] + Facade --> Worktree[Worktree service] + Facade --> Session[Session launch/control service] + Facade --> Workflow[WorkflowService/SchedulerService] + Facade --> Messages[Durable message service] + Facade --> Lifecycle[Lifecycle/review service] + Worktree --> Git[(Git/worktrees)] + Session --> Records[(Run records + receipts)] + Workflow --> Records + Messages --> Journals[(Fsynced JSONL)] + Lifecycle --> Records + Idem --> Requests[(MCP request/result journal)] +``` + +### Adapter layer + +The MCP layer should contain only: + +- protocol registration and annotations; +- conversion between MCP inputs and internal request dataclasses; +- bounded serialization and stable error mapping; +- cancellation/progress forwarding where the underlying operation supports it; +- actor identity injection (`mcp-stdio:`). + +It must not open subprocesses, invoke tmux, mutate Git, write status, or reconstruct workflow state directly. + +### Shared façade + +Introduce a narrow `MutationService` or equivalent composition object that is also callable by the CLI. It should delegate to existing services and return typed result envelopes with: + +- `request_id`; +- `idempotency_key`; +- `action`; +- durable result ID/path/digest; +- state (`accepted`, `pending`, `completed`, `rejected`, `failed`); +- replay indicator; +- bounded diagnostic details. + +Do not refactor stable modules merely to make the MCP code aesthetically uniform. Add seams only where CLI logic currently owns behavior that a second front end legitimately needs. + +## Proposed MCP-003 surface + +### Tools + +| Tool | Mutates | Required inputs | Durable result | +|---|---:|---|---| +| `pack_validate` | no | pack identifier/root | validation result digest | +| `worktree_create` | yes | repository ID, ticket ID, base revision, destination alias, idempotency key | worktree record and Git revision | +| `run_launch` | yes | session ID, worktree ID, prompt/pack ticket ID, class/executor/model options, idempotency key | run ID, command digest, provenance path | +| `workflow_validate` | no | snapshot or authorized template specification | normalized snapshot digest | +| `workflow_start` | yes | workflow run ID, snapshot digest, idempotency key | workflow run record and scheduled node IDs | +| `workflow_status` | no | workflow run ID | replayed workflow projection | +| `workflow_resume` | yes | workflow run ID, snapshot digest, idempotency key | replay/reconciliation result | +| `workflow_seal` | yes | workflow run ID, idempotency key | aggregate receipt digest | +| `workflow_verify` | no | workflow run ID | verification result | +| `run_progress` | yes | run ID, text, actor, idempotency key | message ID/sequence | +| `run_ack` | yes | run ID, source message ID, text, actor, idempotency key | acknowledgement message ID | +| `run_steer` | yes | run ID, text, actor, idempotency key | pending steer message ID | + +`worktree_create`, `run_launch`, and workflow tools accept logical configured-root identifiers. Direct absolute-path arguments should not be exposed to general MCP clients. + +### Resources + +Retain and expand bounded resources: + +- `agent-workflow://runs/{run_id}/status` +- `agent-workflow://runs/{run_id}/messages?after={sequence}&limit={limit}` +- `agent-workflow://runs/{run_id}/receipt` +- `agent-workflow://workflows/{workflow_id}/status` +- `agent-workflow://workflows/{workflow_id}/receipt` +- `agent-workflow://packs/{pack_id}/manifest` +- `agent-workflow://requests/{request_id}` + +Resources return typed JSON, omit secrets and raw terminal output, cap collection sizes, and provide pagination cursors rather than unbounded arrays. + +### Prompts + +Prompts are optional convenience surfaces, not authority. Candidates: + +- `prepare_delegation`: gather bounded fields for a run-launch request; +- `prepare_workflow`: select one of the three authorized templates and generate a draft canonical snapshot; +- `review_run_evidence`: guide a client through status, receipt, scope, and score resources without issuing acceptance. + +Prompt output must never execute a tool implicitly or weaken confirmation requirements. + +## Request and idempotency model + +```mermaid +sequenceDiagram + participant C as MCP client + participant A as Adapter + participant I as Idempotency service + participant S as Shared service + participant J as Durable request journal + C->>A: tool(request, idempotency_key) + A->>I: reserve(action, key, canonical request hash) + alt unseen key + I->>J: append accepted record + fsync + I->>S: execute validated operation + S-->>I: typed result + evidence refs + I->>J: append terminal/pending result + fsync + I-->>A: result replay=false + else same key and same request hash + I-->>A: prior durable result replay=true + else same key and different request hash + I-->>A: conflict error + end + A-->>C: bounded MCP result +``` + +Recommended record envelope: + +```json +{ + "schema": "agent-workflow/mcp-request-event/v1", + "request_id": "uuid", + "sequence": 1, + "idempotency_key": "client-supplied-string", + "action": "workflow_start", + "request_sha256": "…", + "actor": "mcp-stdio:instance-id", + "state": "accepted", + "created_at": "…", + "result_refs": [] +} +``` + +Use append-only events plus a reconstructable projection. Reserve keys before external effects. Replays return the recorded result. A crash after reservation but before completion is recovered by action-specific reconciliation; it is never treated as permission to repeat blindly. + +## Error contract + +Map internal errors to stable public categories while preserving a bounded operator message: + +- `invalid_request` +- `not_found` +- `conflict` +- `policy_denied` +- `unsafe_path` +- `invalid_transition` +- `evidence_invalid` +- `capacity_exceeded` +- `dependency_unavailable` +- `internal_error` + +Do not expose stack traces, command lines containing secrets, raw environment variables, or unrestricted filesystem paths. Include a request ID for local log correlation. + +## Security and authorization + +For stdio, the spawning host process is the security principal. Still enforce: + +- configured repository/pack/worktree/state roots; +- path containment after symlink resolution; +- allowlisted operations and bounded text/collection sizes; +- no raw shell command or arbitrary executable arguments; +- no implicit no-go-model override; +- no destructive/review tools in MCP-003; +- actor, reason, revision, and policy checks in MCP-004; +- response redaction and explicit secret-field denylist; +- per-request timeouts and cancellation boundaries. + +MCP guidance requires servers to validate tool inputs, implement access controls, rate-limit exposed operations, and sanitize outputs. Streamable HTTP additionally requires origin validation and authentication; authorization guidance rejects token passthrough and requires resource/audience validation. These requirements are why HTTP remains a separate decision. + +See `docs/MCP_THREAT_MODEL.md`. + +## MCP-004: destructive and review operations + +Only after MCP-003 has completed conformance, replay, and security gates, add: + +- `run_interrupt` and `run_terminate` (not force kill by default); +- `run_review`, `run_accept`, and `run_reject`; +- optional workflow approval actions that call the same lifecycle service. + +Required controls: + +- explicit feature flags per action class; +- client-side confirmation annotations plus server-side policy checks; +- non-empty actor/reason; +- exact accepted revision; +- immutable score/final-receipt validation; +- independent reviewer checks for high/critical tiers; +- idempotency and duplicate-transition tests; +- denial when evidence is stale, mutable, substituted, or belongs to another run. + +## MCP-005: conditional Streamable HTTP + +No HTTP code should be added until an ADR decides: + +- deployment and tenant model; +- identity provider and OAuth metadata; +- resource indicators/audience validation; +- client registration policy; +- session persistence and horizontal scaling; +- CSRF/origin policy; +- TLS termination and trusted proxies; +- rate limits and abuse controls; +- audit retention and data classification; +- cross-host artifact access/signing. + +Local stdio records should remain portable so the same shared services can later sit behind an authenticated transport. + +## Testing strategy + +### Unit and contract tests + +- request schema bounds and unknown-field rejection; +- canonical request hashing and key conflicts; +- stable error mapping/redaction; +- root containment, symlink, traversal, and case-normalization behavior; +- tool annotations and declared capability list; +- resource pagination/cursors; +- replay of accepted/pending/terminal request events. + +### Integration tests + +- each MCP tool compared with the equivalent CLI/shared-service result; +- duplicate request replay before and after process restart; +- crash injection around reservation, external effect, and terminal event; +- workflow start/resume with dependency failures, approvals, retries, and bindings; +- steer remains pending until correlated acknowledgement; +- no-go model and pane-cap policy are enforced through the canonical launch path; +- final and workflow receipt verification after MCP-issued operations. + +### Protocol/conformance tests + +- official MCP Inspector against the pinned SDK/protocol matrix; +- initialize/capability negotiation; +- malformed JSON-RPC and invalid tool inputs; +- cancellation/progress behavior; +- large-response bounds; +- host matrix for representative clients; +- optional dependency absent: clear startup error, core CLI unaffected. + +### Security tests + +- traversal and symlink races; +- unauthorized roots and forged logical IDs; +- secret-shaped content redaction; +- tool prompt injection cannot bypass policy; +- request-ID/idempotency collision attempts; +- lifecycle receipt substitution and mutable status forgery; +- resource enumeration limits and denial-of-service bounds. + +## Delivery phases + +### Phase A — service extraction and request journal + +- inventory CLI-only logic needed by tools; +- add minimal shared request dataclasses/result envelopes; +- implement durable MCP request event journal and replay projection; +- add canonical hashing and idempotency reservation; +- no new tools until recovery tests pass. + +### Phase B — safe creation and workflow tools + +- implement `worktree_create`, `run_launch`, workflow validate/start/status/resume/seal/verify; +- use configured logical roots and canonical services; +- verify CLI/MCP equivalence and receipt sealing. + +### Phase C — message/control tools + +- implement progress, acknowledgement, and steer; +- preserve pending/delivered/applied distinctions; +- add restart and duplicate-delivery tests. + +### Phase D — conformance and adoption gate + +- run Inspector and representative clients; +- publish capability/compatibility matrix; +- perform threat-model review and release audit; +- decide whether MCP-004 is authorized. + +### Phase E — separately authorized destructive/review tools + +- add feature-gated interrupt/terminate/review/accept/reject; +- repeat security, lifecycle, and host gates. + +### Phase F — HTTP ADR only + +- measure local adoption and identify an actual multi-process/multi-host need; +- approve or reject Streamable HTTP before implementation. + +## Migration and compatibility + +- Existing read-only resources and `pack_validate` remain compatible. +- New tools use versioned internal request schemas and additive MCP names. +- CLI output contracts remain unchanged unless the same shared result envelope is intentionally adopted and tested. +- Durable request records receive migrations through the existing contract migration policy; old records remain replayable. +- Removing the vendored SDK tree has no runtime compatibility impact because it was excluded from packaging and never imported. + +## Observability and operations + +Record structured local events for request accepted/replayed/conflict/completed/failed, action latency, bounded error category, and evidence reference. Do not log tool payloads wholesale. Expose a doctor capability that reports SDK availability, protocol target, enabled tool groups, configured roots, and request-journal health without exposing secrets. + +## Exit criteria + +MCP-003 is complete only when all safe tools use shared services, idempotency survives restart, CLI/MCP behavior matches, receipts verify, traversal/redaction/fuzz tests pass, Inspector passes, documentation/man pages are current, and no destructive or HTTP capability has slipped into scope. diff --git a/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md b/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md index 938981a..ce6428c 100644 --- a/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md +++ b/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md @@ -1,85 +1,57 @@ -# MCP server implementation and routing verification report +# MCP server current implementation report -**Repository baseline:** `agent-workflow` 0.1.8 source archive supplied 2026-07-24 -**Review date:** 2026-07-24 -**Scope:** `mcp-server-next` prompt pack, global routing, tmux wakeups/layout, agent identity, interactive sessions, and model routing +**Release:** 0.2.1 +**Review date:** 2026-07-24 +**Scope:** implemented read-only stdio adapter and boundary verification ## Result -Phase 1 and the locally testable portion of Phase 2 are implemented. MCP remains an optional local stdio adapter and does not become lifecycle authority. No HTTP listener, OAuth surface, arbitrary path access, raw terminal capture, shell execution, direct tmux control, destructive tool, or MCP Task lifecycle was added. +The current MCP integration is an optional local stdio adapter over bounded shared read services. It does not own lifecycle or workflow authority. The executable is `agent-workflow-mcp`; there is no `agent-workflow mcp` subcommand. -## Primary-source refresh +Implemented capabilities: -| Source | Version / state checked | Accessed | Implementation implication | -|---|---|---:|---| -| MCP specification, `modelcontextprotocol.io/specification/2025-11-25` | 2025-11-25 remains the published stable specification on the review date; a later 2026-07-28 release candidate was announced but was not stable | 2026-07-24 | Keep the implementation on the selected stable protocol and do not adopt draft/RC-only behavior. | -| Official Python SDK, `github.com/modelcontextprotocol/python-sdk` | Stable 1.x line; project pin remains `mcp==1.28.1` | 2026-07-24 | Use public `mcp.server.fastmcp.FastMCP` APIs; do not import or vendor private runtime modules. | -| Official SDK testing guide, bundled 1.28.1 snapshot `docs/testing.md` | Public in-memory test helper uses `create_connected_server_and_client_session(app, raise_exceptions=True)` | 2026-07-24 | Protocol-facing tests must use `ClientSession`, not `_tool_manager`, `_resource_manager`, or other private managers. | -| MCP Inspector documentation, `modelcontextprotocol.io/docs/tools/inspector` | Inspector is the official interactive server testing/debugging tool | 2026-07-24 | Inspector execution remains an operator/environment gate because Node packages and the MCP extra are unavailable in this archive environment. | -| MCP debugging guide, `modelcontextprotocol.io/docs/tools/debugging` | Stdio servers log to stderr and Inspector is the recommended first-line tool | 2026-07-24 | Server stdout remains reserved for protocol traffic; startup failures are sent to stderr. | +- bounded run listing and allowlisted status resources; +- durable message resources with cursors/pagination; +- receipt metadata/digest resources without arbitrary artifact reads; +- configured-root pack validation; +- traversal, symlink, missing-resource, redaction, and pagination controls; +- clear optional-dependency failure when `mcp==1.28.1` is absent. -## Architecture review +Not implemented: -The approved boundaries remain sound: +- worktree creation, launch, workflow mutation, progress/ack/steer tools; +- interrupt/terminate/kill, review/accept/reject; +- raw shell, arbitrary commands/paths, environment access, tmux APIs, terminal capture; +- HTTP, OAuth, multi-user authorization, or MCP task lifecycle. -- durable JSON artifacts and receipts remain authoritative; -- MCP calls transport-neutral read services and pack validation only; -- repository and state roots are configured, realpath-contained, and symlink escapes are rejected; -- status output is allowlisted and excludes raw paths and error strings that may disclose local information; -- messages come only from the durable append-only log, never terminal capture; -- receipt resources return bounded names and SHA-256 digests, not arbitrary file bodies; -- MCP does not expose launch, steering, interrupt, kill, tmux, shell, environment, or HTTP surfaces; -- actor identity is instance-scoped as `mcp-stdio:` for future audit use, without claiming a workflow run. +## Dependency and protocol boundary -## Implemented files +The target remains MCP specification `2025-11-25` with optional Python SDK `mcp==1.28.1`. Runtime code uses public `mcp.server.fastmcp.FastMCP` APIs. The unused 141-file vendored SDK source snapshot was removed in 0.2.0; dependency provenance remains in `src/agent_workflow/mcp/SDK_DEPENDENCY.md`. -- `src/agent_workflow/mcp/services.py`: immutable requests/pages, hard pagination bounds, stable service errors, root containment, status redaction, message/receipt listing, and pack validation. -- `src/agent_workflow/mcp/server.py`: thin FastMCP stdio adapter over the shared service boundary. -- `tests/test_mcp_services.py`: invalid IDs, missing runs, pagination limits, redaction, traversal, symlink escapes, and receipt hash behavior. -- `tests/test_mcp_server.py`: optional dependency error behavior and public protocol-facing SDK test shape. +Primary sources reviewed on 2026-07-24: -## Recent orchestration verification +- https://modelcontextprotocol.io/specification/2025-11-25 +- https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization +- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports +- https://modelcontextprotocol.io/specification/2025-06-18/server/tools +- https://github.com/modelcontextprotocol/python-sdk/tree/v1.28.1 +- https://modelcontextprotocol.io/docs/tools/inspector -### Durable tmux wakeups +## Shared-service boundary -`messages.jsonl` remains authoritative. The wake channel is a versioned SHA-256 digest of the resolved run directory. `tmux wait-for` signal/wait failures and timeouts fall back to durable replay polling; no terminal text or retained signal is treated as delivery proof. +`src/agent_workflow/mcp/server.py` registers protocol surfaces. `src/agent_workflow/mcp/services.py` performs bounded identifier/path validation and invokes existing read authorities. The adapter does not start processes, call tmux, write session/workflow state, infer delivery, or generate lifecycle receipts. -### Visible panes and fallback +## Workflow sequencing status -Interactive launches, and non-interactive launches explicitly configured for `shared_window`, resolve the invoking tmux window and split into the configured agent column. Missing/stale tmux context falls back to a dedicated named session. Pane names and targets are persisted. Cosmetic global tmux options are now best-effort and cannot invalidate an otherwise prepared launch; concrete session/pane creation still enforces tmux availability. +WF-22 was completed in 0.2.0 and its workflow authority/replay boundaries were hardened in 0.2.1. The workflow scheduler, approval gates, result binding, aggregate receipts, templates, routing advice, and provider evidence are now stable prerequisites. Therefore `MCP-003` has moved from blocked to ready, but no mutation capability was added in this release. -### Agent naming and profiles +The next implementation must follow [MCP Server Implementation Proposal](MCP_SERVER_IMPLEMENTATION_PROPOSAL.md): durable idempotency first, shared application services only, safe creation/workflow/message tools before destructive/review tools, and no HTTP without a separate authorization ADR. -Preferred names are allocated only when inactive; generated names use the configured prefix and a stable numeric suffix. Explicit names must be preferred or generated-format names. Profiles can constrain class, executor, model, no-go authorization, and interactivity. Active-name reuse is rejected except for controlled restart behavior. +## Verification surfaces -### Agent classes, interactive sessions, and model routing +- `tests/test_mcp_services.py` +- `tests/test_mcp_server.py` +- `tests/test_optional_integrations.py` +- full repository suite and release audit recorded in `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md` -Class policy selects default executor/model, allowed executor-model pairs, and interactive mode. Executor policy separately enforces the executor allowlist and no-go model authorization. Interactive Codex/Claude launches use their TUI entrypoints; non-interactive launches use structured/print commands. Structured execution defaults non-interactive unless explicitly overridden. Receipts and command metadata preserve agent name, class, executor, model, interactive state, and no-go authorization. - -### Global routing - -`docs/GLOBAL_AGENT_ROUTING.md` remains consistent with implementation: semantic classification belongs in global instructions/skills, while `agent-workflow launch` is the durable boundary. Raw host-native subagents or raw tmux processes do not receive workflow worktrees, receipts, policy, or review gates. - -## Verification evidence - -- Focused MCP/tmux/config/session tests: **30 passed, 1 skipped**. -- Full suite before manifest refresh: **100 passed, 1 skipped** through the release audit; the only failure was expected manifest drift from changed/new files. -- MCP protocol test and Inspector execution: **not run locally** because `mcp==1.28.1` and Node-based Inspector dependencies were unavailable in the execution environment. The test is written against the official public in-memory helper and will run when the `mcp` extra is installed. - -## Residual risks and next gate - -Before declaring external-host conformance complete, install the pinned MCP extra in a clean Python 3.11+ environment, run the protocol-facing test, start `agent-workflow-mcp` over stdio, and exercise list/read/call operations with the official Inspector. Keep HTTP, mutations, and lifecycle tools behind separate architecture and security gates. - - -## Workflow-foundation sequencing update - -The completed read-only MCP server remains valid. Remaining mutating MCP tools -are intentionally blocked until canonical backlog task `WF-22` completes the -workflow scheduler, approval gates, structured result binding, aggregate -receipts, authorized templates, routing explanations, and integration review. - -After that gate, MCP Phase 3 may expose bounded workflow validate/launch/status/ -resume operations, but only by calling the same transport-neutral services used -by the CLI. MCP does not parse or mutate workflow state directly and does not -become a scheduler, routing authority, child launcher, approval authority, or -receipt writer of its own. See `prompt-packs/mcp-server-next/phase-3/`. +MCP Inspector and representative external host checks remain environment-dependent gates for the future mutation release. They are not claimed as completed by this local source pass. diff --git a/docs/MCP_THREAT_MODEL.md b/docs/MCP_THREAT_MODEL.md new file mode 100644 index 0000000..bdcc8bf --- /dev/null +++ b/docs/MCP_THREAT_MODEL.md @@ -0,0 +1,84 @@ +# MCP server threat model + +## Assets + +- repository and worktree contents; +- prompt packs and source baselines; +- executor/model/permission policy; +- tmux processes and child agents; +- durable message journals and cursors; +- run artifacts, provider evidence, scores, and receipts; +- lifecycle approvals and accepted revisions; +- local configuration and credentials present in the environment. + +## Trust boundaries + +```mermaid +flowchart TB + subgraph Client[Untrusted/partially trusted MCP client content] + Prompt[Prompt text] + Args[Tool arguments] + end + subgraph Server[Local MCP server process] + SDK[Protocol SDK] + Validation[Schema/bounds validation] + Policy[Authorization + configured roots] + Services[Shared application services] + end + subgraph Host[Trusted local host boundary] + Config[User config] + Git[Repositories/worktrees] + State[Durable state/receipts] + Tmux[tmux/executors] + end + Prompt --> SDK + Args --> SDK + SDK --> Validation --> Policy --> Services + Services --> Config + Services --> Git + Services --> State + Services --> Tmux +``` + +For stdio, the process that launches the server is the authenticated principal. Tool content remains untrusted. A future network transport creates a new identity and tenant boundary and is not covered by stdio assumptions. + +## Threats and mitigations + +| Threat | Example | Required mitigation | +|---|---|---| +| Path traversal | `../../.ssh` or symlinked pack | configured roots, resolved containment, regular-file checks, no arbitrary path tools | +| Command injection | prompt asks tool to append shell flags | no raw command tool; structured executor selection through canonical launch policy | +| Prompt injection | repository text asks client to accept a run | prompts cannot execute tools; server revalidates every request and lifecycle condition | +| Duplicate external effect | client retries `run_launch` after timeout | reserve idempotency key before effect; same key/hash replays; conflict on different hash | +| Crash ambiguity | server dies after worktree created | durable accepted event and action-specific reconciliation before retry | +| Receipt forgery | mutate `status.json` to point at accepted receipt | reconstruct canonical read-only receipt chain and verify sealed artifact digests | +| Artifact substitution | replace child final receipt/result | regular non-symlink read-only receipts, SHA-256 verification, aggregate workflow receipt | +| Policy bypass | request no-go model or interactive mode | canonical class/executor/model/no-go enforcement; no adapter-local override | +| Secret exfiltration | status/error includes token or environment | allowlisted response fields, redaction, no raw environment or terminal output | +| Denial of service | huge message/resource request | strict byte/item/page limits, timeout, cancellation, rate limits for network transport | +| Confused deputy | one client references another root/run | logical IDs resolved within configured authorized roots and future tenant scope | +| Token passthrough | HTTP server forwards client token downstream | prohibited; validate token audience/resource and use separate downstream credentials | +| DNS rebinding/CSRF | malicious browser hits local HTTP server | no HTTP in current scope; future Streamable HTTP must validate Origin and authenticate | +| Lifecycle abuse | accept without independent review | existing tier, score, review-chain, actor, reason, revision, and immutable receipt checks | +| Unsafe cancellation | client cancellation leaves half-written record | append/fync accepted record first; atomic files; terminal/recovery event after reconciliation | + +## Security test matrix + +- encoded and mixed-separator traversal; +- symlink root, leaf, and time-of-check/time-of-use replacement; +- unknown logical IDs and cross-root aliases; +- oversized strings, arrays, pages, and malformed JSON-RPC; +- duplicate idempotency key with same/different canonical payload; +- crash injection at every mutation boundary; +- stale/mutable/substituted lifecycle and final receipts; +- forged snapshot and workflow event sequence; +- no-go model and permission-argument bypass attempts; +- secret-shaped values in status, errors, prompts, and receipts; +- cancellation before reservation, after reservation, after effect, and after terminal record. + +## Residual risks + +- A trusted local user who can modify the same state files can still destroy evidence; read-only modes and hashes detect but do not prevent privileged tampering. +- Executor CLIs and tmux are external processes. Their availability and version behavior require doctor checks and provenance. +- Stdio clients inherit the permissions of the server process. Least-privilege OS execution remains an operational responsibility. +- Multi-user/network deployment requires a new authorization model, not reuse of local path trust. diff --git a/docs/MCP_TOOL_AND_RESOURCE_CATALOG.md b/docs/MCP_TOOL_AND_RESOURCE_CATALOG.md new file mode 100644 index 0000000..82c6619 --- /dev/null +++ b/docs/MCP_TOOL_AND_RESOURCE_CATALOG.md @@ -0,0 +1,73 @@ +# MCP tool, resource, and prompt catalog + +This catalog defines the proposed public surface. `Current` means implemented in the read-only stdio adapter; `MCP-003` and later rows are plans, not present capabilities. + +## Tools + +| Name | Phase | Mutating | Confirmation | Authority/service | Result state | +|---|---|---:|---:|---|---| +| `pack_validate` | Current | no | no | pack validator | completed/failed | +| `worktree_create` | MCP-003 | yes | yes | worktree service | completed/replayed/failed | +| `run_launch` | MCP-003 | yes | yes | canonical session launch | accepted/completed/replayed/failed | +| `workflow_validate` | MCP-003 | no | no | `WorkflowService.validate` | completed/failed | +| `workflow_start` | MCP-003 | yes | yes | `WorkflowService.start` | accepted/completed/replayed/failed | +| `workflow_status` | MCP-003 | no | no | workflow replay | completed/failed | +| `workflow_resume` | MCP-003 | yes | yes | `WorkflowService.resume` | completed/replayed/failed | +| `workflow_seal` | MCP-003 | yes | yes | aggregate receipt service | completed/replayed/failed | +| `workflow_verify` | MCP-003 | no | no | aggregate receipt verifier | completed/failed | +| `run_progress` | MCP-003 | yes | no | durable messages | completed/replayed/failed | +| `run_ack` | MCP-003 | yes | no | durable messages | completed/replayed/failed | +| `run_steer` | MCP-003 | yes | yes | durable messages | pending/replayed/failed | +| `run_interrupt` | MCP-004 | yes | yes | session control | completed/replayed/failed | +| `run_terminate` | MCP-004 | yes | yes | session control | completed/replayed/failed | +| `run_review` | MCP-004 | yes | yes | lifecycle service | completed/replayed/failed | +| `run_accept` | MCP-004 | yes | yes | lifecycle service | completed/replayed/failed | +| `run_reject` | MCP-004 | yes | yes | lifecycle service | completed/replayed/failed | + +Excluded: raw shell, arbitrary command execution, arbitrary file read/write, force kill, merge, raw terminal capture, direct tmux APIs, model-policy bypass, and HTTP administration. + +## Resources + +| URI | Status | Bound | +|---|---|---| +| `agent-workflow://runs` | Current | paginated allowlisted summaries | +| `agent-workflow://runs/{run_id}/status` | Current | redacted typed status | +| `agent-workflow://runs/{run_id}/messages` | Current | sequence cursor and limit | +| `agent-workflow://runs/{run_id}/receipt` | Current | final receipt metadata, not arbitrary artifacts | +| `agent-workflow://workflows/{workflow_id}/status` | MCP-003 | replayed projection | +| `agent-workflow://workflows/{workflow_id}/receipt` | MCP-003 | aggregate receipt metadata | +| `agent-workflow://packs/{pack_id}/manifest` | MCP-003 | configured pack root only | +| `agent-workflow://requests/{request_id}` | MCP-003 | durable idempotency/result projection | + +## Prompts + +| Prompt | Phase | Purpose | +|---|---|---| +| `prepare_delegation` | MCP-003 optional | Produce a validated draft launch request; never launches implicitly. | +| `prepare_workflow` | MCP-003 optional | Produce one authorized template specification/canonical snapshot draft. | +| `review_run_evidence` | MCP-004 optional | Guide inspection of receipts, scope, commands, scores, and lifecycle evidence; never accepts implicitly. | + +## Shared input rules + +- IDs use the repository ID validator and bounded lengths. +- Text inputs are UTF-8, non-empty where required, and capped. +- Paths are configured-root logical references; traversal and symlinks are rejected. +- Unknown fields are rejected. +- Mutations require an idempotency key. +- Executor/model/class fields pass through canonical policy enforcement. +- Review/accept fields require actor, reason, and exact revision as applicable. + +## Stable result envelope + +```json +{ + "schema": "agent-workflow/mcp-result/v1", + "request_id": "uuid", + "action": "run_steer", + "state": "pending", + "replayed": false, + "result_id": "message-uuid", + "evidence": [{"kind": "message", "id": "message-uuid", "sha256": "…"}], + "error": null +} +``` diff --git a/docs/PROVIDER_EVIDENCE_RESEARCH.md b/docs/PROVIDER_EVIDENCE_RESEARCH.md new file mode 100644 index 0000000..5e5faff --- /dev/null +++ b/docs/PROVIDER_EVIDENCE_RESEARCH.md @@ -0,0 +1,125 @@ +# Provider evidence and usage normalization research + +**Status:** implemented in release 0.2.0; hardened in 0.2.1 +**Research date:** 2026-07-24 +**Scope:** Codex/OpenAI-style JSONL, Claude Code stream JSON, immutable run evidence, and comparison-safe token/cost accounting. + +## Decision summary + +`agent-workflow` treats the bounded raw executor event stream as evidence and the normalized usage object as a derived projection. Each classified usage update carries an explicit mode: `delta`, `cumulative`, or `terminal`. A terminal update is authoritative for the run and is never added to earlier deltas. Mixed nonterminal modes invalidate usage. Replayed records with an explicit provider event identity are idempotent. Identical unidentified terminal/cumulative snapshots may be ignored safely, but an identical unidentified `delta` is ambiguous and makes usage incomplete rather than silently undercounting it. Missing or unproven values remain `null`. + +The implementation is in `src/agent_workflow/provider_evidence.py`; contracts are `schemas/provider-evidence.schema.json` and `schemas/trial-evidence.schema.json`; comparison semantics are in `src/agent_workflow/eval/{trials,compare}.py`. + +## Primary sources reviewed + +| Provider surface | Primary source | Verified use | +|---|---|---| +| OpenAI API pricing | https://developers.openai.com/api/docs/pricing | Pricing is model- and date-dependent; runtime estimates require a pinned catalog identity rather than an embedded current price. | +| OpenAI reasoning usage | https://developers.openai.com/api/docs/guides/reasoning | Reasoning-token details may be reported separately from ordinary output tokens. | +| OpenAI prompt caching | https://developers.openai.com/api/docs/guides/prompt-caching | Cached input is a subset/details field and must not be added to input token totals. | +| Codex CLI event examples | https://github.com/openai/codex/issues | Current examples show `turn.completed` usage with input, cached-input, output, and reasoning fields; issue history also demonstrates that CLI fields have changed across releases. | +| Anthropic Messages usage | https://docs.anthropic.com/en/api/messages | Message usage exposes input, cache-creation, cache-read, and output token fields. | +| Anthropic streaming events | https://docs.anthropic.com/en/api/messages-streaming | Streaming has message-start/content-delta/message-delta/message-stop boundaries; intermediate usage is not assumed to be a run total. | +| Anthropic prompt caching | https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching | Cache-read and cache-creation tokens have distinct meanings and billing treatment. | +| Anthropic pricing | https://docs.anthropic.com/en/docs/about-claude/pricing | Provider prices vary by model and cache category; estimates require a pinned price catalog. | +| Claude Code output formats | https://docs.anthropic.com/en/docs/claude-code/cli-reference | `stream-json` is a supported machine-readable CLI surface. | +| Claude Agent SDK result messages | https://docs.anthropic.com/en/api/agent-sdk/python | Final result messages can carry aggregate usage and provider-reported total cost. | + +URLs are recorded because these facts are executor-version-sensitive. The raw event stream and executor version in run provenance remain the evidence needed to reinterpret historical runs. + +## Event envelope + +Every classified update is stored as: + +```json +{ + "sequence": 12, + "event_sha256": "…", + "event_type": "turn.completed", + "mode": "terminal", + "usage": { + "input_tokens": 1000, + "cached_input_tokens": 600, + "cache_write_input_tokens": null, + "output_tokens": 300, + "reasoning_output_tokens": 90, + "provider_total_tokens": 1300, + "provider_billed_cost": null, + "local_estimated_cost": null, + "currency": null, + "price_catalog_id": null + } +} +``` + +### Mode semantics + +- `delta`: values apply only to that event and may be summed with other delta events from the same run. +- `cumulative`: the last valid cumulative update supersedes prior cumulative updates only when numeric counters are nondecreasing and cost metadata is consistent. +- `terminal`: one terminal usage vector is authoritative. Replayed equivalent terminal records are harmless; conflicting terminal vectors invalidate usage. Earlier delta/cumulative records remain provenance but do not fill missing terminal fields. +- mixed `delta` and `cumulative` without a terminal record: invalid, because summing or selecting would require guessing. + +## Field mapping + +| Normalized field | Known variants | +|---|---| +| `input_tokens` | `input_tokens`, `prompt_tokens` | +| `cached_input_tokens` | `cached_input_tokens`, `cache_read_input_tokens`, `cached_tokens`, `input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens` | +| `cache_write_input_tokens` | `cache_write_input_tokens`, `cache_creation_input_tokens` | +| `output_tokens` | `output_tokens`, `completion_tokens` | +| `reasoning_output_tokens` | `reasoning_output_tokens`, `reasoning_tokens`, `output_tokens_details.reasoning_tokens` | +| `provider_total_tokens` | `total_tokens`, `provider_total_tokens` | +| `provider_billed_cost` | explicit provider field such as Claude Code `total_cost_usd`; legacy `cost` only when `cost_source=provider` | +| `local_estimated_cost` | explicit estimate field; legacy `cost` only when `cost_source=local_estimate` | + +Cached input is not a new token category to add to input. It is a billed/details subset of input. Reasoning output is likewise retained as a detail and is not added again when the provider already includes it in output or total tokens. + +## Double-counting hazards and controls + +1. **Terminal plus deltas:** terminal wins; deltas are not summed into it. +2. **Cached input:** cached tokens are never added to `input_tokens`. +3. **Reasoning output:** reasoning details are never added to provider output or total fields. +4. **Duplicate records:** explicit provider event IDs establish replay identity. Without an ID, equivalent terminal/cumulative snapshots may be deduplicated, while duplicate delta lines are flagged as ambiguous and make the run comparison-ineligible. +5. **Retries:** each run has its own evidence and `retry_of_run_id`; cohort aggregation must decide whether retries are separate trials or exclusions rather than merging them silently. +6. **Provider versus estimated cost:** the two fields are never collapsed into one number. +7. **Currency/catalog mismatch:** provider costs are comparable only within one currency; local estimates are comparable only when currency and `price_catalog_id` both match. +8. **Malformed/truncated streams:** usage is incomplete and the trial extractor rejects the run. + +## Bounded capture and immutability + +- Raw executor events must be regular non-symlink files and remain unchanged during capture. Parsing is capped at 16 MiB per run while the complete file is hashed from the same descriptor. +- The evidence records raw byte count, SHA-256, capture limit, completion flag, malformed count, classified records, and incomplete reasons. +- `provider-evidence.json` is sealed into `final-receipt.json`. +- Trial extraction verifies the complete final seal, rejects incomplete provider evidence, and validates `score-set.json` against content-addressed scorer receipts tied to that final receipt. +- The full raw file is hashed with streaming SHA-256; parsing reads at most the declared cap plus one byte. + +## Cost and currency rules + +- `provider_billed_cost` remains `null` unless the provider/executor explicitly reports a billed amount. +- A provider-billed cost requires a currency. +- `local_estimated_cost` remains `null` unless a pinned catalog calculation has been performed. +- A local estimate requires both currency and `price_catalog_id`. +- No current prices are embedded in source. This prevents historical evidence from changing when public pricing changes. +- Cross-cohort cost comparison is excluded when currency or catalog identity differs. + +## Retry, re-steer, and error accounting + +Trial evidence carries: + +- `retry_of_run_id` and runtime `retry_count`; +- structured error records from execution metrics; +- steering count and acknowledged-steering count; +- source artifact digests, including prompt, command collection, provider evidence, and raw events. + +These fields are evidence, not instructions to combine trials. A benchmark manifest must declare inclusion/exclusion semantics for interrupted, retried, or human-assisted runs. + +## Known limits + +- Codex and Claude CLI event JSON are operational interfaces rather than repository-pinned JSON Schemas. Executor version and raw evidence are therefore mandatory provenance. +- Claude assistant-message usage is treated as a per-message delta inside the enclosing CLI run. This is an implementation inference documented in code and must be revalidated when the CLI format changes. +- No paid live baseline/candidate cohort was run in this completion pass. That remains `BKL-004` and requires an explicit benchmark policy decision. +- No local price catalog is shipped; local estimated cost remains `null` unless a future catalog implementation supplies a stable identity. + +## Acceptance evidence + +Focused tests cover terminal authority/conflict/emptiness, delta summation and duplicate ambiguity, cumulative monotonicity, mixed-mode invalidation, cached/reasoning mapping, non-finite values, raw-file type safety, malformed input, size overflow, provider/local cost metadata, forged score-set rejection, incomplete-trial rejection, and comparison exclusions. See `tests/test_provider_evidence.py`, `tests/test_eval_trials.py`, and `tests/test_eval_compare.py`. diff --git a/docs/SECURITY_TRUST_BOUNDARIES.md b/docs/SECURITY_TRUST_BOUNDARIES.md new file mode 100644 index 0000000..051a845 --- /dev/null +++ b/docs/SECURITY_TRUST_BOUNDARIES.md @@ -0,0 +1,34 @@ +# Security trust boundaries + +## Boundary map + +1. **Operator/config input:** trusted intent but validated types, IDs, policy, and paths. +2. **Prompt-pack content:** project-controlled and potentially adversarial; constrained by pack roots, manifests, schemas, checksums, and writable scopes. +3. **Target repository/worktree:** untrusted code executed only through declared commands/executors; isolated per ticket. +4. **Executor process:** external binary with versioned provenance; stdout/stderr are untrusted bounded evidence. +5. **Durable state:** local filesystem authority; atomic JSON, fsynced JSONL, regular-file/symlink checks, and read-only receipts detect substitution. +6. **Reviewer:** separate lifecycle authority; acceptance requires canonical evidence and exact revision. +7. **MCP client:** local stdio principal plus untrusted tool content; current surface is bounded/read-only. Network identity is not yet authorized. + +## Authority matrix + +| Question | Authoritative evidence | Not authoritative | +|---|---|---| +| Did a run execute/finish? | lifecycle events, final status, final receipt | tmux pane text alone | +| Did a child apply a steer? | correlated acknowledgement/application record | steer write, wait-for signal, prose claim | +| Is a run reviewable/approved? | sealed `final-status.json`, canonical final receipt, and canonical lifecycle receipt chain | mutable `status.json` state, tier, executor, digest, or receipt pointer | +| What workflow state exists? | normalized snapshot + contiguous workflow event journal | workflow status projection alone | +| What result value may a child consume? | sealed ancestor `result.json` + binding snapshot | arbitrary files or live source reads | +| What tokens/cost count? | bounded raw provider events + explicit normalization mode | logs, model estimates without catalog | +| Is a release complete? | tests, release audit, manifest, checksums | version string or hand-written report alone | + +## Required controls + +- reject traversal, symlink escapes, unsafe IDs, unknown schema fields, and over-limit data; +- reserve append-only records before projections or external-effect claims; +- preserve prior runs and retry lineage; +- keep provider billed versus estimated cost distinct; +- require exact workflow snapshot matching after start; +- derive lifecycle-review authority from sealed terminal evidence and verify canonical final/lifecycle/workflow receipts as regular non-symlink read-only files; +- never infer approval, delivery, cost, or completion from terminal output; +- avoid vendored third-party source unless it is an intentional maintained dependency boundary. diff --git a/docs/WORKFLOW_FOUNDATIONS_PLAN.md b/docs/WORKFLOW_FOUNDATIONS_PLAN.md index fe6cce3..3f027a8 100644 --- a/docs/WORKFLOW_FOUNDATIONS_PLAN.md +++ b/docs/WORKFLOW_FOUNDATIONS_PLAN.md @@ -1,138 +1,84 @@ -# Workflow Foundations Plan +# Workflow foundations: implemented architecture and remaining evolution -## Decision +**Status:** WF-001, WF-002, WF-00, WF-01, WF-02, WF-10, WF-11, WF-12, WF-20, WF-21, and WF-22 completed in release 0.2.0; authority and replay hardening completed in 0.2.1. -Extend the existing prompt-pack and durable-run model rather than adding a second orchestration system. The repository keeps its current focus: provider-neutral coding-agent launches, isolated worktrees, explicit model policy, durable control records, and immutable evidence. +This document records the implemented boundary. `BACKLOG.md` remains the authority for unfinished work. -The useful ideas adopted are limited to executable dependency graphs, structured task outputs, approval-gated progression, reusable graph templates, and explainable routing advice. No external orchestration runtime, vector memory, self-training, agent persona catalog, federation layer, simulated consensus, invisible hooks, or project-local mutable authority is introduced. +## Implemented foundation -## Invariants +### Dependency graph validation -1. `agent-workflow launch` remains the only durable execution boundary. -2. Prompt-pack manifests remain declarative, reviewable inputs. -3. Append-only run evidence and sealed receipts remain authoritative. -4. A scheduler may decide when a task is eligible; it may not spawn an executor except through the existing launch service. -5. Routing recommendations are advisory. Existing class, executor, model allowlist, and no-go enforcement wins. -6. Structured results are untrusted until bounded, schema-validated, copied into the run directory, and sealed. -7. No arbitrary workflow code or expressions are executed. +Prompt-pack dependencies are validated across phases as a directed acyclic graph. Unknown, malformed, self, duplicate, and cyclic references fail validation before execution. -## Implemented in this change set +### Structured task result contracts -### Dependency-graph validation +Tickets may declare a pack-local JSON Schema. The runner performs bounded regular-file reads of `result.json`, validates it, writes a collection receipt, and seals accepted result evidence. Result contracts do not create a scheduler or arbitrary file channel. -Prompt-pack validation now rejects: +### Workflow snapshot and replay -- unknown dependency IDs; -- self-dependencies; -- dependency cycles; -- malformed dependency lists. +A canonical snapshot defines workflow ID, pack provenance, nodes, dependencies, task/approval kind, launch/routing metadata, retry policy, and optional input bindings. Start stores that snapshot as a regular read-only file. Duplicate node, dependency, and task-session identifiers fail before execution. Append-only events record node transitions and bindings; existing journals are schema/sequence validated before append and read under a shared lock. Status/run files are deterministic projections rebuilt and refreshed from the snapshot and contiguous journal. -Dependencies may cross phase boundaries. This turns the existing `dependencies` field into a reliable graph contract without yet adding an autonomous scheduler. +### Restart-safe scheduler -### Structured task-result contracts +`SchedulerService` schedules only eligible nodes and subtracts already-running nodes from the parallelism budget. A callback return value cannot establish a child run: a matching regular provenance contract or valid sealed final receipt must exist before the node enters `running`. Restart/resume replays durable records, reconciles sealed terminal child outcomes, marks missing child footprints recoverable, propagates dependency failure, and reopens dependency-failed descendants when a prerequisite retry changes the dependency state. Attempts and retry lineage remain append-only. -A task may declare: +### Receipt-backed approvals -```yaml -result_contract: - schema: contracts/audit-result.schema.json - required: true -``` - -For such a ticket, the launch context instructs the child to atomically write `AGENT_WORKFLOW_HANDOFF_DIR/result.json`. The runner: - -- resolves the schema inside the prompt-pack root; -- rejects escaping or symlinked schema paths; -- reads a bounded, non-symlink result file; -- validates it using JSON Schema 2020-12; -- stores the accepted value as `result.json` in the run directory; -- writes `collections/task-result.json`; -- seals both artifacts when present. - -The generic `agent-workflow/task-result/v1` schema is provided for simple tasks, while prompt packs may carry narrower task-specific schemas. - -## Remaining minimal architecture - -### 1. Dependency scheduler +Approval nodes reference a subject task. They complete only when the subject has a valid canonical accepted lifecycle receipt chain for the sealed child run and exact completion revision. Lifecycle receipt creation and workflow approval reconstruction derive authority from sealed terminal artifacts and ignore mutable `status.json` state, identity, tier, digest, and receipt pointers. Rejection or tampering fails the gate and propagates dependency failure. -Add a small workflow-run service over an already validated prompt pack. +### Bounded result binding -Required behavior: +A task may bind named inputs from completed ancestor task results with strict RFC 6901 JSON Pointers. The source result and collection must be sealed by a canonical final receipt. Required/missing behavior, per-value size, total size, ancestry, and unsafe file types are enforced. Values are copied into a read-only binding snapshot and sealed child provenance; children never read arbitrary predecessor files dynamically. -- create an immutable workflow snapshot from pack manifest and checksum; -- mark tasks `blocked`, `eligible`, `running`, or terminal from dependencies and child receipts; -- launch eligible tasks only through the existing launch domain service; -- enforce configured maximum parallelism; -- stop dependent tasks after failed, blocked, or rejected prerequisites according to explicit policy; -- reconstruct state from the snapshot and append-only workflow events after restart; -- bind each workflow node to exactly one current run and retain retry lineage. +### Aggregate workflow receipts -Do not add arbitrary JavaScript/Python workflow execution, loops, timers, distributed queues, or a second task database. +A terminal workflow can be sealed into a read-only receipt committing to the normalized/file snapshot digests, event journal digest/count, exact node set and states, terminal reasons, attempts/retry lineage, binding history and input digests, child final receipt/completion digests, approval digests, and workflow disposition. Verification rebuilds the value from current durable evidence and requires exact equality. -### 2. Approval gates +### Authorized templates -Represent an approval gate as a declarative node that becomes satisfied only when a valid lifecycle/review receipt references the expected child final receipt digest. +Only these deterministic expansion templates exist: -Required behavior: +1. `pipeline` +2. `parallel-review-fan-in` +3. `implementation-independent-review` -- no mutable boolean approval flag as authority; -- no implicit approval from task completion; -- explicit accepted/rejected disposition; -- downstream eligibility derived from durable receipt evidence. +Expansion produces the same canonical snapshot schema as hand-authored workflows. -### 3. Structured output binding +### Explainable routing advice -Allow a downstream task to bind selected fields from a validated predecessor result. +Routing advice deterministically recommends exploratory, review, or implementation class from bounded node metadata and records stable explanation codes. It is advisory. Existing configured class/executor/model allowlists, interactivity, permission arguments, and no-go policy remain the enforcement authority. Recommendation/enforced selection disagreements are explicit. -Constraints: +## Non-targets retained -- JSON Pointer only; -- bounded serialized value size; -- no template expression language; -- snapshot the resolved inputs into the child run provenance; -- fail closed when required fields are absent. +- no alternate executor or MCP-specific scheduler; +- no online learning, vector memory, or autonomous model selection; +- no arbitrary expression language or file reads in result binding; +- no approval inferred from logs, status pointers, or process success; +- no automatic merge or destructive workflow cleanup; +- no multi-host broker until a measured need and explicit decision. -### 4. Reusable workflow templates +## CLI -Templates should be ordinary validated YAML fragments for a small set of recurring shapes: pipeline, parallel review with fan-in, and implementation followed by independent review. Templates must expand to the same workflow schema before execution. - -Do not create named agent personas or broad methodology catalogs. - -### 5. Explainable routing advice - -Add a deterministic advisory function that recommends an existing agent class, executor, model, and interactive mode from declared task metadata and measured historical cohorts. - -First release requirements: - -- fixed rules only; -- stable explanation codes; -- recommendation recorded separately from enforced selection; -- policy rejection recorded when recommendation is disallowed; -- no online training, embeddings, vector database, or silent config mutation. +```text +agent-workflow workflow validate SNAPSHOT +agent-workflow workflow template TEMPLATE SPEC --output SNAPSHOT +agent-workflow workflow start RUN_DIR SNAPSHOT +agent-workflow workflow status RUN_DIR SNAPSHOT +agent-workflow workflow resume RUN_DIR SNAPSHOT +agent-workflow workflow seal RUN_DIR SNAPSHOT +agent-workflow workflow verify RUN_DIR SNAPSHOT +``` -Evidence-derived statistical recommendations are a later enhancement only after benchmark evidence tasks BKL-003 through BKL-005 are complete. +The supplied snapshot must match the stored started snapshot for every operation after start. -## Explicit non-targets +## Evidence and tests -- external orchestration-runtime integration; -- replacing prompt packs; -- vector or graph memory; -- self-learning or model fine-tuning; -- large catalogs of named or specialized agent personas; -- consensus algorithms for local coding agents; -- multi-host federation; -- HTTP workflow control; -- automatic background hooks that launch or reroute agents; -- arbitrary workflow scripts or condition expressions; -- hidden mutable state inside target repositories. +Schemas live in `schemas/workflow-*.schema.json`, `schemas/routing-advice.schema.json`, and `schemas/workflow-input-bindings.schema.json`. Focused tests live in `tests/test_workflow.py`, `tests/test_approval.py`, `tests/test_workflow_receipt.py`, `tests/test_workflow_templates.py`, and `tests/test_routing.py`. -## Delivery order +See [Repository Chart Pack](diagrams/REPOSITORY_CHART_PACK.md) for state, authority, binding, approval, and receipt diagrams. -1. Finish scheduler state model and restart-safe event journal. -2. Add approval nodes and aggregate workflow receipts. -3. Add JSON Pointer result binding. -4. Add only the three justified template shapes. -5. Add deterministic routing explanations. -6. Reassess statistical routing only after sealed benchmark evidence exists. +## Remaining evolution -The linked prompt pack contains the executable remaining work. +- `MCP-003`: expose safe workflow/session/message mutations through shared services with durable idempotency. +- `BKL-004`: run controlled real-executor benchmark cohorts. +- `WF-006`: consider evidence-derived routing recommendations only after comparable cohort evidence exists. diff --git a/docs/diagrams/REPOSITORY_CHART_PACK.md b/docs/diagrams/REPOSITORY_CHART_PACK.md new file mode 100644 index 0000000..16af0b5 --- /dev/null +++ b/docs/diagrams/REPOSITORY_CHART_PACK.md @@ -0,0 +1,499 @@ +# agent-workflow repository chart pack + +**Release:** 0.2.1 +**Purpose:** current-state architecture, data/evidence model, execution flows, security boundaries, and planned MCP evolution. + +Mermaid sources for the highest-value diagrams are also stored as individual `.mmd` files in this directory. + +## 1. System context + +```mermaid +flowchart LR + Operator[Operator / orchestrator] --> CLI[agent-workflow CLI] + AgentHost[Codex / Claude host] --> Skills[Installed workflow skills] + Skills --> CLI + CLI --> Core[Application services] + Core --> Git[Git repositories + isolated worktrees] + Core --> Tmux[tmux sessions/panes] + Tmux --> Executors[Codex / Claude executors] + Executors --> Artifacts[Run artifacts + JSONL evidence] + Core --> Artifacts + Core --> Workflow[Workflow graph scheduler] + Workflow --> Core + Reviewer[Independent reviewer] --> CLI + CLI --> Receipts[Immutable receipts + lifecycle decisions] + MCPHost[MCP host] --> MCP[Read-only stdio MCP adapter] + MCP --> Core +``` + +## 2. Package/component map + +```mermaid +flowchart TB + cli[cli.py] --> config[config.py] + cli --> sessions[sessions.py] + cli --> workflow_service[workflow_service.py] + cli --> lifecycle[lifecycle.py] + cli --> messages[messages.py] + cli --> evalpkg[eval/*] + cli --> pack[pack.py/manifests.py] + sessions --> executors[executors.py] + sessions --> runner[runner.py] + sessions --> tmux[tmux.py] + sessions --> state[state.py] + runner --> metrics[metrics.py] + runner --> provider[provider_evidence.py] + runner --> receipts[receipts.py] + workflow_service --> scheduler[scheduler.py] + scheduler --> workflow[workflow.py] + scheduler --> routing[routing.py] + scheduler --> bindings[bindings.py] + scheduler --> approval[approval.py] + workflow_service --> wf_receipt[workflow_receipt.py] + approval --> lifecycle + bindings --> receipts + wf_receipt --> receipts + evalpkg --> provider + mcp[mcp/server.py] --> mcp_services[mcp/services.py] + mcp_services --> state + mcp_services --> messages + mcp_services --> receipts + contracts[contracts.py + schemas/] --> cli + contracts --> sessions + contracts --> workflow + contracts --> evalpkg +``` + +## 3. Authority hierarchy + +```mermaid +flowchart TB + subgraph Authoritative + Snapshot[Normalized workflow snapshot] + Events[Append-only workflow events.jsonl] + Control[Append-only control/messages JSONL] + Raw[Bounded raw executor events] + Final[Read-only final-receipt.json] + Life[Read-only lifecycle receipt chain] + WFR[Read-only workflow-receipt.json] + end + subgraph Derived + Status[status.json] + WFStatus[workflow-status.json] + WFRun[workflow-run.json] + Metrics[execution-metrics.json] + Provider[provider-evidence.json] + Trial[trial-evidence.json] + Terminal[tmux capture / logs] + end + Snapshot --> WFStatus + Events --> WFStatus + Snapshot --> WFRun + Events --> WFRun + Raw --> Provider + Provider --> Trial + Final --> Trial + Life --> WFStatus + Final --> WFR + Life --> WFR + Snapshot --> WFR + Events --> WFR + Control --> Status + Terminal -. observational only .-> Status +``` + +## 4. Session run artifact tree + +```mermaid +flowchart TB + Run[run/<session-id>] + Run --> Prompt[prompt.md / launch-prompt.md] + Run --> Command[command.json] + Run --> Baseline[source-baseline.json] + Run --> Provenance[run-provenance.json] + Run --> Events[executor-events.jsonl] + Run --> Logs[output.log / executor-stderr.log] + Run --> Completion[completion.md + completion.json] + Run --> Collections[collections/*] + Run --> Result[result.json + task-result collection] + Run --> Controls[control-events.jsonl / assignments.jsonl] + Run --> Metrics[execution-metrics.json] + Run --> Provider[provider-evidence.json] + Run --> Status[status.json + final-status.json] + Run --> Patch[patch.diff] + Run --> Final[final-receipt.json] + Run --> Scores[scores/*] + Run --> Lifecycle[receipts/000001-reviewed.json ...] + Final -->|hashes| Prompt + Final -->|hashes| Command + Final -->|hashes| Provenance + Final -->|hashes| Events + Final -->|hashes| Completion + Final -->|hashes| Provider +``` + +## 5. Delegated run lifecycle + +```mermaid +stateDiagram-v2 + [*] --> prepared + prepared --> running: launch + running --> completed: executor exit 0 + collection + seal + running --> failed: nonzero/error/budget + running --> interrupted: interrupt + running --> killed: terminate/kill + interrupted --> retry_prepared: restart + failed --> retry_prepared: restart + killed --> retry_prepared: restart + retry_prepared --> running: new run ID, retry_of lineage + completed --> reviewed: lifecycle receipt + reviewed --> accepted: exact revision + valid evidence + reviewed --> rejected: lifecycle receipt + completed --> rejected: lifecycle receipt + accepted --> [*] + rejected --> [*] +``` + +## 6. Executor/runner evidence flow + +```mermaid +sequenceDiagram + participant S as Session service + participant T as tmux/process + participant E as Executor CLI + participant R as Runner + participant D as Run directory + S->>D: write prompt, command, baseline, provenance + S->>T: launch canonical runner command + T->>E: configured argv + model/permissions + E-->>R: stdout JSONL/text + stderr + R->>D: append bounded executor-events.jsonl + R->>D: write logs/completion/collections/patch + R->>D: derive provider-evidence.json + R->>D: derive execution-metrics.json + R->>D: write final-status.json + R->>D: under seal.lock, atomically install read-only final-receipt.json + R-->>S: terminal result +``` + +## 7. Durable control/message flow + +```mermaid +sequenceDiagram + participant P as Parent/orchestrator + participant J as fsynced message JSONL + participant W as tmux wait-for hint + participant C as Child/adapter + P->>J: append steer(message_id) + fsync + P->>W: best-effort wakeup + C->>J: replay after cursor + C->>J: append progress/ack(correlation_id) + fsync + C->>W: best-effort wakeup + P->>J: replay after cursor + Note over P,C: Lost/coalesced wakeups do not lose records; journals are authoritative. +``` + +## 8. Workflow scheduler and replay + +```mermaid +flowchart TD + Load[Load read-only normalized snapshot] --> Replay[Shared-lock replay of contiguous workflow events] + Replay --> Project[Reconstruct node/workflow state] + Project --> Reconcile[Verify running child provenance or sealed terminal evidence] + Reconcile --> RetryDeps[Reopen dependency-failed descendants when prerequisite retry changes state] + RetryDeps --> FailDeps[Propagate current dependency failures] + FailDeps --> Capacity[Subtract existing running nodes from parallelism budget] + Capacity --> Eligible{Eligible pending nodes within capacity?} + Eligible -->|approval node| Approval[Verify canonical lifecycle evidence] + Eligible -->|task node| Inputs[Resolve sealed predecessor bindings] + Approval --> Transition[Append node transition + fsync] + Inputs --> Route[Compute advisory routing] + Route --> Launch[Canonical session launch service] + Launch --> Footprint{Matching durable child footprint exists?} + Footprint -->|yes| Bind[Append running transition] + Footprint -->|no| Recoverable[Append recoverable transition and fail closed] + Bind --> Project + Recoverable --> Project + Transition --> Project + Eligible -->|none| Terminal{All nodes terminal?} + Terminal -->|no| Projection[Refresh workflow status/run projections] + Terminal -->|yes| Seal[Seal aggregate workflow receipt] +``` + +## 9. Approval gate + +```mermaid +flowchart LR + Subject[Completed child run] --> Final[Canonical read-only final receipt] + Subject --> Completion[Sealed completion + exact head revision] + Subject --> Scores[Sealed score set when required] + Final --> Review[000001-reviewed.json] + Scores --> Review + Review --> Accept[000002-accepted.json] + Completion --> Accept + Accept --> Chain[Reconstruct canonical contiguous lifecycle chain] + Chain --> Gate{Digest, revision, score, independence valid?} + Gate -->|yes| Open[Approval node completed] + Gate -->|no| Closed[Approval node failed closed] + Mutable[status.json state/tier/executor/digest/pointer] -. projection only .-> Chain +``` + +## 10. Result binding + +```mermaid +flowchart LR + Source[Completed predecessor node] --> Child[Bound child run] + Child --> Receipt[Verify canonical final receipt] + Receipt --> Result[Verify sealed result.json + collection digest] + Result --> Pointer[Resolve strict RFC 6901 JSON Pointer] + Pointer --> Bounds{Ancestor, required, per-value and total bounds valid?} + Bounds -->|no| Fail[Fail closed; child not launched] + Bounds -->|yes| Snapshot[Atomically install read-only parent binding snapshot] + Snapshot --> Provenance[Atomically install read-only child workflow-inputs.json and bind digest in provenance] + Provenance --> Launch[Launch child through canonical session service] +``` + +## 11. Workflow aggregate receipt + +```mermaid +flowchart TB + Snap[workflow-snapshot.json] --> Receipt[workflow-receipt.json] + Journal[workflow-events.jsonl] --> Receipt + Nodes[Exact node list/state/reason] --> Receipt + History[Attempts/retry/binding history] --> Receipt + Inputs[Input-binding digests] --> Receipt + Child[Child final-receipt + completion digests] --> Receipt + Approvals[Approval receipt digests] --> Receipt + Receipt --> Verify[Rebuild from durable evidence and compare exactly] + Verify --> ReadOnly[Atomically installed read-only under workflow.lock] +``` + +## 12. Authorized graph templates + +```mermaid +flowchart TB + subgraph Pipeline + P1[step-1] --> P2[step-2] --> P3[step-3] + end + subgraph ParallelReviewFanIn + I[implementation] --> R1[review-1] + I --> R2[review-2] + R1 --> F[fan-in] + R2 --> F + end + subgraph ImplementationIndependentReview + A[implementation] --> B[independent review] + B --> G[approval gate] + end +``` + +## 13. Deterministic routing advice + +```mermaid +flowchart TD + Node[Workflow node metadata] --> Classify{Keywords/risk/explicit class} + Classify -->|research/discovery/spike| Exploratory[recommend exploratory] + Classify -->|review/audit/security| Review[recommend review] + Classify -->|otherwise| Implementation[recommend implementation] + Exploratory --> Policy[Existing configured class/executor/model policy] + Review --> Policy + Implementation --> Policy + Policy --> NoGo{No-go model?} + NoGo -->|unauthorized| Reject[Reject] + NoGo -->|allowed/none| Enforced[Record recommendation, enforced selection, explanation codes, disagreements] +``` + +## 14. Provider usage normalization + +```mermaid +flowchart LR + Raw[Stable regular non-symlink executor-events.jsonl] --> Hash[Bounded parse + complete raw SHA-256] + Hash --> Identity[Provider event identity or duplicate ambiguity] + Identity --> Classify{Provider/event boundary} + Classify --> Delta[delta] + Classify --> Cumulative[cumulative] + Classify --> Terminal[terminal] + Delta --> Merge[Merge rules] + Cumulative --> Merge + Terminal --> Merge + Merge --> Complete{Consistent, finite, monotonic, identified, untruncated?} + Complete -->|yes| Evidence[provider-evidence.json] + Complete -->|no| Incomplete[Evidence with incomplete reasons] + Evidence --> Seal[final-receipt.json] + Seal --> Trial[trial-evidence/v2] + Incomplete --> Reject[Trial extraction rejects] +``` + +## 15. Evaluation/cohort flow + +```mermaid +flowchart TD + Run[Sealed run] --> Extract[extract_trial] + Extract --> Checks[Verify final seal + complete provider evidence + content-addressed score receipts] + Checks --> Trial[Immutable trial record] + Trial --> Baseline[Baseline cohort] + Trial --> Candidate[Candidate cohort] + Baseline --> Compare[eval compare] + Candidate --> Compare + Compare --> Validity{Same fixture/policy/currency/catalog?} + Validity -->|yes| Stats[paired metrics, deltas, confidence/effect output] + Validity -->|no| Exclusions[explicit excluded comparisons/reasons] +``` + +## 16. Prompt-pack execution model + +```mermaid +flowchart LR + Pack[pack.yaml + phase manifests] --> Validate[DAG/schema/checksum validation] + Validate --> Phase[Ordered phase] + Phase --> Ticket[Bounded ticket + writable paths + acceptance] + Ticket --> Worktree[Isolated worktree] + Worktree --> Run[Delegated run] + Run --> Result[Structured task result + completion] + Result --> Review[Independent review] + Review --> Accept{Accepted?} + Accept -->|no| Repair[Correction ticket/run] + Repair --> Review + Accept -->|yes| Gate[Phase gate + integration] +``` + +## 17. tmux topology and capacity + +```mermaid +flowchart TB + Window[Orchestrator tmux window] + Window --> Left[Orchestrator pane] + Window --> C1[Agent column 1] + Window --> C2[Agent column 2] + C1 --> A1[agent slot 1] + C1 --> A2[agent slot 2] + C1 --> A3[agent slot 3] + C2 --> A4[agent slot 4] + C2 --> A5[agent slot 5] + C2 --> A6[agent slot 6] + Detached[Non-interactive run] --> Dedicated[Dedicated detached session] + Cap{Pane cap reached} --> Prompt[close explicit idle / detached / cancel] +``` + +## 18. CLI dispatch + +```mermaid +flowchart LR + Parser[argparse live parser] --> Doctor[doctor/config] + Parser --> Worktree[worktree] + Parser --> Launch[launch/list/status/attach/tail/restart/control] + Parser --> Agent[agent reuse/context] + Parser --> Workflow[workflow validate/start/status/resume/seal/verify/template] + Parser --> Lifecycle[review/accept/reject] + Parser --> Eval[eval collect/score/compare/report] + Parser --> Pack[pack scaffold/validate/checksums/archive] + Parser --> Completion[shell completion] +``` + +## 19. Release/install flow + +```mermaid +flowchart LR + Source[Git source checkout] --> Audit[audit-release-assets.py] + Audit --> Manifest[MANIFEST.sha256] + Source --> Tests[pytest + compileall + shell syntax] + Manifest --> ReleaseCheck[scripts/release-check.sh] + Tests --> ReleaseCheck + ReleaseCheck --> Archive[deterministic tar.zst + SHA-256] + Source --> Install[install.sh editable install] + Install --> Launcher[~/.local/bin/agent-workflow] + Install --> SkillLinks[shared/Codex/Claude skill symlinks] +``` + +## 20. Current and target MCP boundary + +```mermaid +flowchart TB + Host[MCP host] + Host --> Stdio[Local stdio FastMCP] + Stdio --> Current[Current: bounded read-only resources + pack_validate] + Current --> Shared[Existing shared read services] + Future[MCP-003 planned safe mutations] --> Idem[Durable idempotency journal] + Idem --> SharedMut[Existing worktree/session/workflow/message services] + Destructive[MCP-004 review/control] -. separately gated .-> SharedMut + HTTP[MCP-005 Streamable HTTP] -. ADR required .-> Auth[OAuth/audience/origin/rate-limit boundary] + Auth -. after decision .-> SharedMut +``` + +## 21. Evidence-oriented ERD + +```mermaid +erDiagram + PROMPT_PACK ||--o{ PHASE : contains + PHASE ||--o{ TICKET : contains + TICKET ||--o{ SESSION_RUN : executes_as + SESSION_RUN o|--o| SESSION_RUN : retries + SESSION_RUN ||--|| RUN_PROVENANCE : records + SESSION_RUN ||--o{ EXECUTOR_EVENT : emits + SESSION_RUN ||--o{ CONTROL_MESSAGE : exchanges + SESSION_RUN ||--|| COMPLETION : produces + SESSION_RUN ||--o| TASK_RESULT : produces + SESSION_RUN ||--o| PROVIDER_EVIDENCE : normalizes + SESSION_RUN ||--|| FINAL_RECEIPT : seals + FINAL_RECEIPT ||--o{ LIFECYCLE_RECEIPT : reviewed_by + WORKFLOW ||--|| WORKFLOW_SNAPSHOT : defined_by + WORKFLOW ||--o{ WORKFLOW_NODE : contains + WORKFLOW ||--o{ WORKFLOW_EVENT : records + WORKFLOW_NODE o|--o| SESSION_RUN : binds + WORKFLOW_NODE o{--o{ INPUT_BINDING : consumes + WORKFLOW_NODE o|--o| LIFECYCLE_RECEIPT : gated_by + WORKFLOW ||--o| WORKFLOW_RECEIPT : seals + FINAL_RECEIPT ||--o{ TRIAL_EVIDENCE : supports + PROVIDER_EVIDENCE ||--o{ TRIAL_EVIDENCE : supports + TRIAL_EVIDENCE }o--|| COHORT : grouped_in + COHORT }o--o{ COMPARISON : compared_by + + SESSION_RUN { + string session_id PK + string retry_of FK + string status + string worktree + } + WORKFLOW { + string workflow_id PK + string snapshot_sha256 + string state + } + WORKFLOW_NODE { + string node_id PK + string kind + string state + int attempt + } + FINAL_RECEIPT { + string sha256 PK + string session_id FK + datetime sealed_at + } + WORKFLOW_RECEIPT { + string sha256 PK + string workflow_id FK + string workflow_state + } +``` + +## 22. Security trust boundaries + +```mermaid +flowchart TB + UserInput[Prompts/config/tool inputs] --> Validation[Schema + bounds + ID validation] + Validation --> Policy[Configured roots + class/model/no-go policy] + Policy --> Services[Application services] + Services --> GitBoundary[Git/worktree boundary] + Services --> ProcessBoundary[tmux/executor boundary] + Services --> StateBoundary[Durable state boundary] + ProcessBoundary --> Raw[Untrusted executor output] + Raw --> Collector[Bounded collectors] + Collector --> Seals[Immutable digests/receipts] + StateBoundary --> Seals + Seals --> Review[Independent review/acceptance] +``` + +## Diagram maintenance rule + +Update this chart pack whenever a release changes a durable authority, package boundary, workflow state, public CLI family, MCP capability, evidence schema, or release/install flow. Historical plans may retain old diagrams only when clearly labeled as historical. diff --git a/docs/diagrams/authority-hierarchy.mmd b/docs/diagrams/authority-hierarchy.mmd new file mode 100644 index 0000000..52f2e09 --- /dev/null +++ b/docs/diagrams/authority-hierarchy.mmd @@ -0,0 +1,12 @@ +flowchart TB + Snapshot[Workflow snapshot] --> Projection[Workflow projection] + Events[Append-only workflow events] --> Projection + Raw[Raw executor JSONL] --> Provider[Provider evidence] + Final[Final receipt] --> Trial[Trial evidence] + Provider --> Trial + Lifecycle[Lifecycle receipt chain] --> Approval[Approval projection] + Final --> WorkflowReceipt[Aggregate workflow receipt] + Lifecycle --> WorkflowReceipt + Snapshot --> WorkflowReceipt + Events --> WorkflowReceipt + Logs[Logs/tmux/status] -. observational or derived .-> Projection diff --git a/docs/diagrams/evidence-erd.mmd b/docs/diagrams/evidence-erd.mmd new file mode 100644 index 0000000..4c27691 --- /dev/null +++ b/docs/diagrams/evidence-erd.mmd @@ -0,0 +1,16 @@ +erDiagram + PROMPT_PACK ||--o{ PHASE : contains + PHASE ||--o{ TICKET : contains + TICKET ||--o{ SESSION_RUN : executes_as + SESSION_RUN o|--o| SESSION_RUN : retries + SESSION_RUN ||--|| RUN_PROVENANCE : records + SESSION_RUN ||--o{ EXECUTOR_EVENT : emits + SESSION_RUN ||--o{ CONTROL_MESSAGE : exchanges + SESSION_RUN ||--|| FINAL_RECEIPT : seals + FINAL_RECEIPT ||--o{ LIFECYCLE_RECEIPT : reviewed_by + WORKFLOW ||--|| WORKFLOW_SNAPSHOT : defined_by + WORKFLOW ||--o{ WORKFLOW_NODE : contains + WORKFLOW ||--o{ WORKFLOW_EVENT : records + WORKFLOW_NODE o|--o| SESSION_RUN : binds + WORKFLOW ||--o| WORKFLOW_RECEIPT : seals + FINAL_RECEIPT ||--o{ TRIAL_EVIDENCE : supports diff --git a/docs/diagrams/mcp-target-architecture.mmd b/docs/diagrams/mcp-target-architecture.mmd new file mode 100644 index 0000000..d072cc9 --- /dev/null +++ b/docs/diagrams/mcp-target-architecture.mmd @@ -0,0 +1,10 @@ +flowchart LR + Client[MCP client] --> Adapter[FastMCP stdio adapter] + Adapter --> Validate[Validation/policy] + Validate --> Idem[Durable idempotency journal] + Idem --> Services[Shared application services] + Services --> Worktree[Worktrees] + Services --> Session[Sessions] + Services --> Workflow[Workflow scheduler] + Services --> Messages[Durable messages] + Services --> Lifecycle[Lifecycle receipts] diff --git a/docs/diagrams/provider-evidence.mmd b/docs/diagrams/provider-evidence.mmd new file mode 100644 index 0000000..39669df --- /dev/null +++ b/docs/diagrams/provider-evidence.mmd @@ -0,0 +1,9 @@ +flowchart LR + Raw[Stable regular non-symlink raw JSONL] --> Capture[Bounded parse + complete hash] + Capture --> Identity[Provider event identity / duplicate ambiguity] + Identity --> Mode{delta/cumulative/terminal} + Mode --> Merge[Fail-closed finite and monotonic merge] + Merge --> Evidence[provider-evidence.json] + Evidence --> Seal[final receipt] + Seal --> Scores[Validate content-addressed score receipts] + Scores --> Trial[trial evidence] diff --git a/docs/diagrams/workflow-scheduler.mmd b/docs/diagrams/workflow-scheduler.mmd new file mode 100644 index 0000000..ecc301d --- /dev/null +++ b/docs/diagrams/workflow-scheduler.mmd @@ -0,0 +1,18 @@ +flowchart TD + Load[Load read-only normalized snapshot] --> Replay[Shared-lock replay of contiguous workflow events] + Replay --> Project[Reconstruct state] + Project --> Reconcile[Verify running child provenance or sealed terminal evidence] + Reconcile --> RetryDeps[Reopen dependency-failed descendants after prerequisite retry] + RetryDeps --> Capacity[Subtract existing running nodes from capacity] + Capacity --> Eligible{Eligible nodes within capacity?} + Eligible -->|approval| Approval[Verify canonical lifecycle chain] + Eligible -->|task| Inputs[Resolve sealed bounded inputs] + Inputs --> Route[Advisory routing] + Route --> Launch[Canonical session launch] + Launch --> Footprint{Durable child footprint?} + Footprint -->|yes| Event[Append running transition + fsync] + Footprint -->|no| Recoverable[Append recoverable transition] + Approval --> Event + Event --> Replay + Recoverable --> Replay + Eligible -->|none + terminal| Seal[Seal workflow receipt] diff --git a/docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md b/docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md new file mode 100644 index 0000000..790489e --- /dev/null +++ b/docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md @@ -0,0 +1,62 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-3 +ticket: BKL-003-RESEARCH +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# BKL-003-RESEARCH Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Completed a primary-source research memo for provider-neutral usage evidence and current supported executor event surfaces. Facts, implementation inferences, unknown/null behavior, cached-token variants, currency/catalog rules, duplicate/retry semantics, and real-executor cohort exclusions are separated explicitly. + +## Files changed + +- `docs/PROVIDER_EVIDENCE_RESEARCH.md` +- supporting architecture/security/MCP research documents +- implementation checklist embodied in schemas/tests + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| Primary official sources recorded with access dates | pass | OpenAI, Anthropic, and MCP official documentation references | +| Delta/cumulative/terminal envelope specified | pass | memo and schema design | +| Cached/reasoning/cost variants and double-counting risks mapped | pass | provider mapping table and invalid-mode rules | +| Unknowns/open cohort questions are explicit | pass | BKL-004 remains open; no paid-provider result claimed | +| No runtime change in research ticket | pass | research memo is separable from subsequent implementation commit scope | + +## Tests and validation + +Read-only source/reference validation and prompt-pack validation passed. No fresh external research-review executor was available; this limitation is recorded rather than replaced with a fabricated receipt. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/BKL-003-TICKET-COMPLETION.md b/docs/execution-evidence/BKL-003-TICKET-COMPLETION.md new file mode 100644 index 0000000..93ff6f5 --- /dev/null +++ b/docs/execution-evidence/BKL-003-TICKET-COMPLETION.md @@ -0,0 +1,65 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-3 +ticket: BKL-003 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# BKL-003 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Implemented bounded raw provider stream preservation, event-level digests/sequences, explicit usage modes, provider adapters, sealed provider evidence, retry/error/control accounting, immutable trial evidence, and valid cohort comparison semantics. + +## Files changed + +- `src/agent_workflow/provider_evidence.py`, runner/provenance/metrics/receipt integration +- evaluation trial collection/comparison +- provider, trial, provenance, and metrics schemas +- provider/eval/runner/session tests + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| Raw bounded stream evidence is preserved before normalization | pass | 16 MiB max+1 capture, digest, byte count, completeness flag | +| Usage modes do not double-count | pass | terminal authority; mixed nonterminal modes invalidate fields | +| Duplicate/replayed events are idempotent | pass | event-digest deduplication tests | +| Unknown tokens/cost/currency remain null | pass | null tests and schema contracts | +| Provider billed and local estimate are distinct | pass | separate fields; currency/catalog comparison exclusions | +| Retry/re-steer/error and provenance digests are retained | pass | sealed provenance lineage, controls, metrics, and trial source hashes | +| Incomplete/inconsistent trials are rejected | pass | evaluation collection/comparison tests | + +## Tests and validation + +`PYTHONPATH=src python3 -m pytest -q tests/test_provider_evidence.py tests/test_metrics.py tests/test_eval_trials.py tests/test_eval_compare.py tests/test_runner_generation.py` — focused provider/evaluation/runner coverage passed. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/FINAL_CRITICAL_REVIEW.md b/docs/execution-evidence/FINAL_CRITICAL_REVIEW.md new file mode 100644 index 0000000..5290f40 --- /dev/null +++ b/docs/execution-evidence/FINAL_CRITICAL_REVIEW.md @@ -0,0 +1,67 @@ +--- +schema: agent-workflow/phase-gate/v1 +pack_id: chatgpt-workflow-completion-next +phase: release-0.2.1-critical-repair +review_session: local-critical-repair-20260724 +decision: accepted_with_follow_up +--- + +# Release 0.2.1 Critical Review + +## Supersession notice + +This report supersedes the 0.2.0 final critical review. The earlier conclusion that no known correctness or security defect remained was too optimistic. A fresh review reproduced and corrected material scheduler, replay, evidence-authority, and concurrent-sealing defects. The 0.2.0 gate remains in Git history and `WORKFLOW_BENCHMARK_PHASE_GATE.md` only as historical evidence. + +## Review method + +The delivered source archive was extracted independently at commit `4ece38c` and treated as an untrusted release candidate. The review traced workflow state from canonical files through replay, scheduling, projection, retry, sealing, trial extraction, and provider normalization; wrote regression tests before repairs; ran focused negative/tamper suites; audited documentation and release metadata; and repeated the cumulative suite outside the foreground tool timeout. + +## Defects found and corrected + +1. **Active-capacity oversubscription.** Existing `running` nodes did not consume `max_parallelism`. Planning now subtracts active nodes before selecting work. +2. **Circular child authority.** A historical workflow event or a callback result containing only `run_id` could prove a child existed. Running state now requires a matching regular provenance contract or verified final receipt in the canonical child run directory. +3. **Missing terminal reconciliation.** Restarted workflows did not automatically consume sealed child outcomes. Running children are now reconciled from final status, completion collection, and the verified final receipt. +4. **Retry replay contradiction.** The CLI allowed retry from `recoverable`, but event replay rejected the resulting binding. Replay and transition rules now agree. +5. **Permanently failed descendants.** Dependency-propagated failures did not reopen after a prerequisite retry. Those nodes return to `blocked`, then become eligible only after the prerequisite completes. +6. **Projection authority and staleness.** Start/status logic depended on `workflow-run.json`, and projections could be stale immediately after scheduling. The immutable snapshot and event journal establish a started workflow; mutable projections are regenerated after scheduling and on status reads. +7. **Snapshot and journal durability gaps.** Canonical snapshots remained writable, journal appends did not validate prior content under one lock, and directory entries were not always fsynced. Snapshots and workflow receipts are made read-only before their atomic rename; snapshots are descriptor-validated; journals reject symlink/non-regular substitutions, validate contiguous history before append, lock stable reads, and persist directory-entry changes. +8. **Concurrent sealing race.** Receipt construction could reconstruct one journal state and hash another while scheduling appended events. Scheduling, sealing, verification, and projection refresh now share a descriptor-safe workflow lock. +9. **Duplicate graph identifiers.** Duplicate dependency IDs and task session IDs were accepted. Normalization and JSON Schema now reject both. +10. **Mutable score verdict authority.** Trial extraction trusted `score-set.json` without validating the referenced scorer receipts. Extraction now validates content-addressed receipts, expected scorers, final-receipt binding, and the derived verdict. +11. **Provider usage undercount and false completeness.** Empty/conflicting terminal totals, non-finite values, regressing cumulative totals, incomplete currency/catalog metadata, and equal unidentified delta events could be accepted or silently deduplicated. These cases now fail closed; explicit event identities remain idempotent and distinct identified deltas count independently. +12. **Raw stream substitution.** Provider capture could follow symlinks or observe a file changing between parse and hash. Capture now requires one regular non-symlink descriptor, hashes the complete stream, bounds parsing, and rejects in-flight mutation. +13. **Receipt-root and final-seal races.** Lifecycle code resolved `receipts/` before testing for a symlink, and final-run seal creation/verification lacked a shared serialization boundary. Receipt roots and seal locks now reject symlinks/non-regular files, directory entries are fsynced, and final receipts/artifacts are read and hashed from stable descriptors. +14. **Split receipt verification and digest reads.** Several consumers verified `final-receipt.json` and then hashed its pathname in a second operation; aggregate workflow receipts used the same split read/stat/hash pattern. Verification now returns the digest of the exact final-receipt bytes read under `seal.lock`, and workflow receipts are parsed, mode-checked, and hashed from one non-symlink descriptor under `workflow.lock`. +15. **Mutable content-addressed scorer receipts.** Scorer receipts used content-derived filenames but were writable and could be read through symlinks. They are now atomically installed read-only, validated from regular non-symlink descriptors, and rejected if an existing content-addressed path is writable or disagrees with its object. Lifecycle review hashes the exact score-set bytes it validated. +16. **Post-verification sealed-artifact reopen.** Lifecycle review, approval gates, scheduler reconciliation, result binding, workflow receipt construction, and trial extraction verified a final seal and then reopened sealed JSON by pathname. Those authority paths now read the exact receipt-listed artifact through beneath-root, no-symlink descriptors and recheck size/hash before using it. +17. **Incomplete and unsafe read-only pass.** The optional `assignments/` evidence tree was omitted, and chmod could follow a symlink to an outside target. All sealed required/optional trees are now covered, symlinks are rejected, and chmod is descriptor-based. Seal creation and verification also reject intermediate symlink components even when they point back inside the run. +18. **Writable copied launch inputs.** Parent workflow binding snapshots were read-only, but the child copy and native-job binding artifacts remained writable until terminal sealing. Parent and child workflow inputs, native-job snapshots, and binding receipts are now made read-only before their atomic rename and before executor launch. +19. **Stale release evidence.** The feature ledger, MCP follow-on baseline, man pages, cleanup audit, phase-gate status, diagrams, backlog history, and version markers still represented 0.2.0 as current. Current surfaces now identify 0.2.1 and explicitly preserve 0.2.0 only as historical provenance. + +## Verification evidence + +| Gate | Result | +|---|---| +| Focused authority, evidence, receipt, launch, and immutable-input suites | 101 passed; 13 subtests passed | +| Cumulative suite excluding release-manifest tests | 235 passed; 1 optional integration skipped; 49 subtests passed in 43.74 seconds | +| Full repository suite with regenerated release manifest | 238 passed; 1 optional integration skipped; 49 subtests passed in 45.12 seconds | +| Schema, compilation, shell syntax, prompt-pack, and manifest checks | Passed | +| Source/release archive and wheel checks | Recorded in the final delivery summary after packaging | + +## Boundary and simplification audit + +- Canonical append-only records and sealed receipts remain authoritative; mutable status, run projections, logs, and terminal capture do not. +- No second scheduler, executor, daemon, broker, database, HTTP service, arbitrary shell/path surface, or MCP mutation implementation was added. +- The shared workflow lock is a local coordination primitive, not a new state store. +- Historical prompt packs and ticket reports remain for reproducibility but are labeled as history rather than active architecture or task tracking. +- No additional vendored dependency or removable runtime subsystem was found after the 0.2.0 SDK cleanup. + +## Remaining follow-up + +- `BKL-004` still requires a controlled real-provider cohort; no model or routing winner is claimed from fixtures. +- MCP mutation work remains `MCP-003`, ready but not implemented. +- No independent external Codex/Claude reviewer executable was available. This review therefore records local executable evidence and does not fabricate an external receipt. + +## Decision + +Accept release 0.2.1 with the external-review and real-provider-cohort follow-ups above. Unlike the superseded 0.2.0 report, this decision explicitly records the defects discovered after delivery and the regression boundaries added for each repair. diff --git a/docs/execution-evidence/PHASE_GATE_REPORT.md b/docs/execution-evidence/PHASE_GATE_REPORT.md index 11f2e9c..a180148 100644 --- a/docs/execution-evidence/PHASE_GATE_REPORT.md +++ b/docs/execution-evidence/PHASE_GATE_REPORT.md @@ -1,5 +1,7 @@ # Phase Gate Report +**Historical evidence notice:** This report records the earlier skills/MCP Phase 0-1 gate and is not the current workflow-completion gate. Current 0.2.1 correction evidence is recorded in `WORKFLOW_BENCHMARK_PHASE_GATE.md` and `FINAL_CRITICAL_REVIEW.md`. + ## Decision **Accept Phase 0 with validation caveat; accept Phase 1 research artifact.** diff --git a/docs/execution-evidence/WF-10-TICKET-COMPLETION.md b/docs/execution-evidence/WF-10-TICKET-COMPLETION.md new file mode 100644 index 0000000..042ac23 --- /dev/null +++ b/docs/execution-evidence/WF-10-TICKET-COMPLETION.md @@ -0,0 +1,61 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-1 +ticket: WF-10 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# WF-10 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Implemented receipt-backed approval gates over the canonical lifecycle receipt directory. The original delegated WF-10 commit was not accepted because it followed a mutable receipt path from `status.json`; this completion replaces that authority with contiguous regular-file/read-only chain reconstruction and also hardens lifecycle receipt creation to derive completion, tier, executor identity, and final-receipt digest from sealed terminal evidence. + +## Files changed + +- `src/agent_workflow/approval.py`, `src/agent_workflow/lifecycle.py`, `src/agent_workflow/scheduler.py` +- `tests/test_approval.py`, `tests/test_lifecycle.py` +- workflow/architecture/security documentation + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| Accepted and rejected paths are distinct | pass | scheduler transitions approval nodes from canonical accepted/rejected dispositions | +| Mutable approval state is not authoritative | pass | status-projection tamper test plus direct canonical receipt reconstruction | +| Tampered, unrelated, copied, omitted, duplicated, symlinked, writable, or noncontiguous receipts fail closed | pass | `tests/test_approval.py` | +| Downstream eligibility follows durable evidence | pass | scheduler approval/dependency tests | + +## Tests and validation + +`PYTHONPATH=src python3 -m pytest -q tests/test_lifecycle.py tests/test_approval.py tests/test_workflow_receipt.py` — 17 passed, 4 subtests passed. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-11-TICKET-COMPLETION.md b/docs/execution-evidence/WF-11-TICKET-COMPLETION.md new file mode 100644 index 0000000..5717c38 --- /dev/null +++ b/docs/execution-evidence/WF-11-TICKET-COMPLETION.md @@ -0,0 +1,61 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-1 +ticket: WF-11 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# WF-11 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Implemented declared JSON Pointer result binding from completed ancestor task results. Values and source digests are copied into immutable per-node/per-attempt snapshots and child provenance before launch; downstream agents do not dynamically read predecessor files. + +## Files changed + +- `src/agent_workflow/bindings.py`, scheduler/provenance integration +- workflow input-binding schemas +- `tests/test_bindings.py` and workflow retry coverage + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| Strict JSON Pointer subset, no expression language | pass | malformed escapes, array tokens, leading-zero indices, and non-pointer forms rejected | +| Valid sealed ancestor results only | pass | collection and final-receipt digests verified | +| Missing required and oversized values fail closed | pass | per-value 1 MiB and aggregate 4 MiB limits tested | +| Retry/replay behavior is deterministic | pass | immutable snapshot replays byte-for-byte with preserved `created_at` | + +## Tests and validation + +`PYTHONPATH=src python3 -m pytest -q tests/test_bindings.py tests/test_workflow.py` — focused binding/retry coverage passed. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-12-TICKET-COMPLETION.md b/docs/execution-evidence/WF-12-TICKET-COMPLETION.md new file mode 100644 index 0000000..234a4f3 --- /dev/null +++ b/docs/execution-evidence/WF-12-TICKET-COMPLETION.md @@ -0,0 +1,61 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-1 +ticket: WF-12 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# WF-12 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Implemented terminal aggregate workflow receipts and verification. The receipt commits to the canonical normalized snapshot, snapshot file, append-only event journal, exact node set/states, retry/binding history, child final/completion digests, input bindings, and revalidated canonical approval evidence. + +## Files changed + +- `src/agent_workflow/workflow_receipt.py`, workflow service/CLI seal and verify paths +- `schemas/workflow-receipt.schema.json` +- `tests/test_workflow_receipt.py` + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| Snapshot and event-log digests | pass | receipt rebuild verifies both digests and event count | +| Exact node/run/child/approval evidence | pass | node set and evidence rebuilt from durable state | +| Substitution, omission, duplication, and partial workflows fail | pass | explicit negative tests | +| Post-seal approval tampering fails verification | pass | canonical lifecycle evidence is revalidated during rebuild | + +## Tests and validation + +`PYTHONPATH=src python3 -m pytest -q tests/test_workflow_receipt.py tests/test_workflow.py` — focused receipt/replay coverage passed. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-20-TICKET-COMPLETION.md b/docs/execution-evidence/WF-20-TICKET-COMPLETION.md new file mode 100644 index 0000000..4efb033 --- /dev/null +++ b/docs/execution-evidence/WF-20-TICKET-COMPLETION.md @@ -0,0 +1,62 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-2 +ticket: WF-20 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# WF-20 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Implemented only the three authorized deterministic graph-template expansions: pipeline, bounded parallel review with fan-in, and implementation followed by independent review. Expansion returns the same canonical workflow snapshot contract used by hand-authored workflows. + +## Files changed + +- `src/agent_workflow/workflow_templates.py` +- workflow template CLI parsing/help +- `tests/test_workflow_templates.py` +- command reference and workflow man page + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| All three authorized shapes | pass | each topology has structural assertions | +| Deterministic canonical expansion | pass | repeated expansion is byte-equivalent after canonical serialization | +| Invalid parameters fail closed | pass | identifiers, counts, and required fields tested | +| No methodology/persona catalog | pass | implementation exposes no extra template registry | + +## Tests and validation + +`PYTHONPATH=src python3 -m pytest -q tests/test_workflow_templates.py tests/test_cli_parsing.py` — focused template/CLI coverage passed. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-21-TICKET-COMPLETION.md b/docs/execution-evidence/WF-21-TICKET-COMPLETION.md new file mode 100644 index 0000000..57ba434 --- /dev/null +++ b/docs/execution-evidence/WF-21-TICKET-COMPLETION.md @@ -0,0 +1,62 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-2 +ticket: WF-21 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# WF-21 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Implemented deterministic pure routing advice over bounded task metadata. Advice recommends the existing exploratory/review/implementation classes and their configured executor/model/interactivity, while launch-time configuration and no-go policy remain authoritative. + +## Files changed + +- `src/agent_workflow/routing.py` +- `schemas/routing-advice.schema.json` +- scheduler records recommendation, enforced selection, actual selection, explanation codes, and disagreements +- `tests/test_routing.py` + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| Stable rule/explanation output | pass | deterministic repeated-call tests | +| Recommendation and enforcement remain separate | pass | disagreement fields tested | +| No-go policy fails closed | pass | all-no-go candidate test | +| No online learning, embeddings, or config mutation | pass | pure service; no persistence or alternate launcher | + +## Tests and validation + +`PYTHONPATH=src python3 -m pytest -q tests/test_routing.py tests/test_session_launch.py` — focused routing/policy coverage passed. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-22-TICKET-COMPLETION.md b/docs/execution-evidence/WF-22-TICKET-COMPLETION.md new file mode 100644 index 0000000..deee3ed --- /dev/null +++ b/docs/execution-evidence/WF-22-TICKET-COMPLETION.md @@ -0,0 +1,63 @@ +--- +schema: agent-workflow/ticket-completion/v1 +pack_id: chatgpt-workflow-completion-next +phase: phase-2 +ticket: WF-22 +session: local-chatgpt-execution-20260724 +result: completed +base_revision: c219eb7 +head_revision: 2ed7b8e +--- + +# WF-22 Ticket Completion Report + +## Source baseline + +| Repository/component | Revision before | Revision after | Dirty before | +|---|---|---|---| +| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | + +## Scope delivered + +Performed the cumulative workflow/security/documentation integration review. Closed mutable authority leaks in approval creation, approval consumption, child binding, aggregate receipts, retry accounting, and snapshot substitution. Removed the unused vendored MCP SDK, refreshed every current documentation surface, added man pages and a 22-diagram chart pack, and left MCP mutations as a separately authorized backlog item. + +## Files changed + +- cumulative runtime, schema, test, docs, skill, CLI/help, man, backlog, release, and cleanup changes +- `docs/diagrams/REPOSITORY_CHART_PACK.md` +- `CLEANUP_AND_REMOVAL_AUDIT.md`, `FEATURE_TEST_LEDGER.md` +- final gate reports in this directory + +## Acceptance criteria + +| Criterion | Result | Evidence | +|---|---|---| +| No alternate launch path | pass | scheduler invokes the existing `sessions.launch` path only | +| No hidden mutable authority | pass | sealed/canonical evidence used for approvals, bindings, retry metrics, and workflow receipts | +| No external-project terminology | pass | repository-wide stale/drift scan | +| Docs/backlog/skills/help/man are current | pass | local link, parser, version, and stale-claim audits | +| Simplification review | pass | 141-file unused vendored SDK subtree removed | + +## Tests and validation + +Final grouped suite and release commands are recorded in `WORKFLOW_BENCHMARK_PHASE_GATE.md`. + +## Tests intentionally not added + +No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. + +## Migration and compatibility notes + +Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. + +## Unresolved issues or source contradictions + +A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. + +## No-drift declaration + +- [x] No unrelated external-project behavior was added. +- [x] No superfluous test class was added. +- [x] No live target collection or paid-provider cohort was performed. +- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. +- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md b/docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md new file mode 100644 index 0000000..e082d9a --- /dev/null +++ b/docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md @@ -0,0 +1,56 @@ +> **Superseded review notice:** This 0.2.0 gate is historical. The 0.2.1 critical repair in `FINAL_CRITICAL_REVIEW.md` invalidated its claim that no correctness defect remained. + +--- +schema: agent-workflow/phase-gate/v1 +pack_id: chatgpt-workflow-completion-next +phase: cumulative-workflow-and-provider-evidence +review_session: local-critical-review-20260724 +decision: accepted_with_follow_up +--- + +# Workflow and Benchmark Phase Gate + +## Ticket status + +| Ticket | Branch/commit | Review result | Notes | +|---|---|---|---| +| WF-00 / WF-01 / WF-02 | historical accepted commits through `ea77a74` | accepted | Existing workflow contracts, restart-safe scheduler, and recovery CLI were reverified. | +| WF-10 | corrected in `2ed7b8e` | accepted after correction | Original delegated implementation was rejected for trusting a mutable receipt path. Canonical chain reconstruction and sealed lifecycle-authority hardening replace it. | +| WF-11 | `2ed7b8e` | accepted | Bounded immutable result bindings and replay idempotency verified. | +| WF-12 | `2ed7b8e` | accepted | Aggregate receipt rebuild and tamper cases verified. | +| WF-20 / WF-21 | `2ed7b8e` | accepted | Exactly three deterministic templates; routing remains advisory. | +| WF-22 | `2ed7b8e` plus final evidence commit | accepted | Integration/security/docs/cleanup/release review completed. | +| BKL-003-RESEARCH / BKL-003 | `2ed7b8e` | accepted | Primary-source design, bounded provider evidence, trial collection, and comparison implemented. | +| BKL-004 | backlog | deferred | No paid/live real-executor cohort was run or claimed. | + +## Independent gate commands + +| Command | Result summary | +|---|---| +| Focused workflow/provider/lifecycle/evaluation suites | Final complete suite: 212 passed, 1 optional MCP protocol test skipped, and 49 subtests passed in 40.91 seconds. | +| `bash -n install.sh uninstall.sh scripts/*.sh prompt-packs/*/scripts/*.sh` | passed | +| Python compile and all JSON schema parses | passed | +| Live CLI and MCP `--help` probes | passed; MCP `--repo-root` documentation corrected | +| Prompt-pack checksum and validation for both changed packs | passed; workflow pack 4 phases/11 tasks, MCP pack 4 phases/12 tasks | +| `python3 scripts/audit-release-assets.py --write-manifest` and `python3 scripts/audit-release-assets.py` | passed; release assets valid | + +## Boundary audit + +- [x] Authority and ownership boundaries remain intact. +- [x] No alternate launch service, scheduler, daemon, broker, database, or HTTP transport was added. +- [x] Mutable status projections do not control lifecycle receipt creation or workflow approval decisions. +- [x] No unsupported provider values, model winner, paid cohort, or cost inference is claimed. +- [x] Tests correspond to replay, tamper, schema, limits, accounting, and policy contracts. +- [x] Documentation, skills, backlog, CLI reference, and man pages describe implemented behavior. +- [x] External-project terminology and local-directory references were removed from current surfaces. + +## Rejected or deferred work + +- MCP mutation tools remain `MCP-003`, ready but not implemented. +- Destructive/review MCP tools and HTTP remain separately gated. +- BKL-004 remains the operator-run real-executor cohort. +- A fresh external Codex/Claude reviewer was unavailable. Historical independent reviews are retained, and the cumulative correction was reviewed locally with executable negative/tamper tests. No independent receipt is fabricated. + +## Decision rationale + +The cumulative implementation is accepted for release 0.2.0. The decision is `accepted_with_follow_up` only because the prompt pack required a fresh independent external-agent review and no suitable executor binary was available in this environment. That follow-up does not hide a known failing local gate; it is an explicit provenance limitation. diff --git a/docs/man/agent-workflow-mcp.1 b/docs/man/agent-workflow-mcp.1 new file mode 100644 index 0000000..118c8b8 --- /dev/null +++ b/docs/man/agent-workflow-mcp.1 @@ -0,0 +1,17 @@ +.TH AGENT-WORKFLOW-MCP 1 "2026-07-24" "agent-workflow 0.2.1" "User Commands" +.SH NAME +agent-workflow-mcp \- optional local stdio MCP adapter +.SH SYNOPSIS +.B agent-workflow-mcp +.RI [ --config " PATH" ] +.RI [ --repo-root " PATH" ] +.SH DESCRIPTION +Starts the optional MCP server over stdio using the pinned public Python MCP SDK. The current server exposes bounded read-only run/status/message/receipt resources and pack validation through shared services. The optional --repo-root argument constrains the repository root used for pack validation. +.SH EXCLUSIONS +The current server does not expose launch, workflow mutation, steering, review/acceptance, interrupt/terminate/kill, raw shell, arbitrary paths, tmux APIs, raw terminal capture, or HTTP. +.SH SECURITY +Access is restricted to configured roots with identifier, traversal, symlink, redaction, pagination, and response-size controls. Standard output is reserved for protocol traffic; diagnostics go to standard error. +.SH DEPENDENCY +Install the optional mcp extra. The SDK is not vendored; the selected dependency is mcp==1.28.1. +.SH SEE ALSO +agent-workflow(1), docs/MCP_SERVER_IMPLEMENTATION_REPORT.md, docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md, docs/MCP_THREAT_MODEL.md diff --git a/docs/man/agent-workflow-workflow.1 b/docs/man/agent-workflow-workflow.1 new file mode 100644 index 0000000..a20e4b5 --- /dev/null +++ b/docs/man/agent-workflow-workflow.1 @@ -0,0 +1,40 @@ +.TH AGENT-WORKFLOW-WORKFLOW 1 "2026-07-24" "agent-workflow 0.2.1" "User Commands" +.SH NAME +agent-workflow-workflow \- restart-safe workflow graph commands +.SH SYNOPSIS +.B agent-workflow workflow validate +.I SNAPSHOT +.br +.B agent-workflow workflow template +.I TEMPLATE SPEC +.B --output +.I SNAPSHOT +.br +.B agent-workflow workflow start +.I RUN_DIR SNAPSHOT +.br +.B agent-workflow workflow status +.I RUN_DIR SNAPSHOT +.br +.B agent-workflow workflow resume +.I RUN_DIR SNAPSHOT +.br +.B agent-workflow workflow seal +.I RUN_DIR SNAPSHOT +.br +.B agent-workflow workflow verify +.I RUN_DIR SNAPSHOT +.SH DESCRIPTION +Workflow commands operate on a normalized immutable snapshot and an append-only event journal. start stores the canonical snapshot and schedules eligible nodes through the existing session launch service. status and resume replay durable events. seal creates a read-only aggregate workflow receipt after every node is terminal. verify rebuilds that receipt from current durable evidence. +.SH TEMPLATES +Authorized TEMPLATE values are pipeline, parallel-review-fan-in, and implementation-independent-review. Template expansion is deterministic and produces the canonical workflow snapshot schema. +.SH APPROVALS +Approval nodes verify the subject run's canonical contiguous lifecycle receipt chain, sealed final receipt, exact completion revision, and score evidence. Mutable status pointers are not authority. +.SH RESULT BINDINGS +Task inputs may use strict RFC 6901 JSON Pointers into sealed completed ancestor results. Values are constrained by ancestry and byte limits, copied into a read-only binding snapshot, and sealed into child provenance. Arbitrary file reads and expression evaluation are not supported. +.SH SNAPSHOT CONSISTENCY +Every command after start must supply a snapshot whose normalized SHA-256 matches the stored started snapshot. Substitution is rejected. +.SH FILES +Within RUN_DIR: workflow-snapshot.json, workflow-events.jsonl, workflow-status.json, workflow-run.json, bindings/, and optional workflow-receipt.json. +.SH SEE ALSO +agent-workflow(1), docs/WORKFLOW_FOUNDATIONS_PLAN.md, docs/diagrams/REPOSITORY_CHART_PACK.md diff --git a/docs/man/agent-workflow.1 b/docs/man/agent-workflow.1 new file mode 100644 index 0000000..096f60d --- /dev/null +++ b/docs/man/agent-workflow.1 @@ -0,0 +1,65 @@ +.TH AGENT-WORKFLOW 1 "2026-07-24" "agent-workflow 0.2.1" "User Commands" +.SH NAME +agent-workflow \- terminal-first delegated execution, workflow, evidence, and review CLI +.SH SYNOPSIS +.B agent-workflow +.RI [ --config " PATH" ] +.RI [ --json ] +.I COMMAND +.RI [ ARGS ] +.SH DESCRIPTION +agent-workflow creates isolated Git worktrees, launches configured coding executors through tmux, stores durable run evidence, supports restart-safe workflow graphs, and seals review and evaluation receipts. Durable JSON/JSONL records and read-only receipts are authoritative; tmux capture and logs are observational. +.SH COMMAND FAMILIES +.TP +.B doctor, config, completion +Environment/configuration inspection and generated shell completion. +.TP +.B worktree +Create, list, and remove isolated ticket worktrees. +.TP +.B launch, list, status, attach, tail, restart +Launch and observe delegated sessions. +.TP +.B steer, progress, ack, watch +Append and replay durable control/progress records. A steer is pending until correlated evidence proves acknowledgement/application. +.TP +.B interrupt, terminate, kill +Process controls that preserve run evidence. Kill is immediate and should be reserved for explicit operator use. +.TP +.B agent +Inspect bounded interactive-agent context and perform same-worktree reuse. +.TP +.B workflow +Validate, template, start, replay, resume, seal, and verify workflow graphs. See agent-workflow-workflow(1). +.TP +.B review, accept, reject +Append immutable lifecycle receipts. Acceptance requires exact revision and valid evidence. +.TP +.B eval, ledger +Validate/score/report evaluation runs, collect immutable trials, compare cohorts, and render pack ledgers. +.TP +.B pack +Scaffold, validate, checksum, and deterministically archive prompt packs. +.SH GLOBAL OPTIONS +.TP +.B --config PATH +Use an alternate TOML configuration path. +.TP +.B --json +Request machine-readable output where supported. +.TP +.B --version +Print the release version. +.SH EVIDENCE +Run evidence is normally stored below ~/.local/state/agent-workflow/runs/SESSION. final-receipt.json seals required and optional artifacts. Review dispositions are separate read-only receipts. Structured provider evidence is bounded and sealed before trial extraction. +.SH MCP +The optional local stdio server is a separate executable, agent-workflow-mcp(1). It is currently bounded/read-only plus pack validation; planned mutation tools are not present. +.SH FILES +.TP +.I ~/.config/agent-workflow/config.toml +Default user configuration. +.TP +.I ~/.local/state/agent-workflow/ +Default durable state root. +.SH SEE ALSO +agent-workflow-workflow(1), agent-workflow-mcp(1), docs/COMMAND_REFERENCE.md, docs/ARCHITECTURE.md diff --git a/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 b/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 index a59e5e5..71ec440 100644 --- a/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 +++ b/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 @@ -1,7 +1,7 @@ 7e0976f8f782192607ad0fb6c8b88306d7647228e787f87f7c814267cb4e892e CHATGPT_HANDOFF_PROMPT.md 5bb7334ba2b658d9810f1c0a8780c5eb51acec4597f85d3ddc73fe97935e7ea1 DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -9aab264abbaf1b85f1ba86598bf6468fed54289042a9be62d7a99b370c7cef03 README.md +7a4f042f4b9c6935c9b2cfee509f0783dc94f20fe7ad335ed0b5d2b4a5d5ec62 README.md a34bd83458605c47cc4f74aa3c1261dc4f6f185647521e8916738d46508d1276 contracts/workflow-node-result.schema.json a70069129389ff6c4f8affe3fb94d8b91e8b42b1a72a69dd6bfd45e392f979a5 pack.yaml f7523df45911ef669bcc812d26f8cef88a1ca8f8aa2f4fe5ac05dbd5a2321247 phase-0/MASTER_IMPLEMENTATION_PROMPT.md diff --git a/prompt-packs/chatgpt-workflow-completion-next/README.md b/prompt-packs/chatgpt-workflow-completion-next/README.md index addf20f..d3fdb74 100644 --- a/prompt-packs/chatgpt-workflow-completion-next/README.md +++ b/prompt-packs/chatgpt-workflow-completion-next/README.md @@ -1,5 +1,7 @@ # ChatGPT Workflow Foundations and Benchmark Completion +> Status: executed against release 0.2.0; retained as the authoritative implementation input and reproducibility pack. + This pack is the ordered implementation handoff for ChatGPT. It completes WF-10, WF-11, WF-12, WF-20, WF-21, WF-22, and BKL-003, including the research needed to make BKL-003 safe and evidence-backed. Work must continue through diff --git a/prompt-packs/mcp-server-next/MANIFEST.sha256 b/prompt-packs/mcp-server-next/MANIFEST.sha256 index ac97c6d..3bf8b1c 100644 --- a/prompt-packs/mcp-server-next/MANIFEST.sha256 +++ b/prompt-packs/mcp-server-next/MANIFEST.sha256 @@ -1,10 +1,10 @@ 838a6333e2fd160f71c8d3d6610a6e0521e412f92181f5332f3ceba1f696110b CHATGPT_HANDOFF_PROMPT.md a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -9ead09fbcf8a8af2c954d9c1cbb95439a579c16ff5e7bfcb86a88b66a47ba10e README.md -03bd1bbcac7331cc09143ad35b2219726a9be177b84810d0ffa7b1ad53c1340e pack.yaml +40f034a20cbcbb928fec310bbe56b27c73104ea48108b792677fc26de352be64 README.md +4a4b64b2de7997a23d3f36365c8afc05cef4462771a7a19bc653c4c705690abf pack.yaml d4e8b40779a4293ad8f15face0b4cd3b37ec78b79f005b910bde644f329ec3be phase-0/MASTER_IMPLEMENTATION_PROMPT.md -69d77a1833cfd23d085f3609511501d8d43f6818ca3bc842ab96e952b8b8cadd phase-0/README.md +d9c303351fd8f94fd9191f5aa97b704609af94f4ffb45ae188c8d041f4cbce5b phase-0/README.md e27d2cd479f66651472ba2a942b8503eaef5c23c5c7986fb06afa8fb2dfe03e1 phase-0/task-manifest.yaml ea63a1bf8f3ef5c4ca13d866001776c3cfb043d6ccb80ef9e0663b9246cd2ae8 phase-0/tickets/P0-00-baseline-and-preflight.md e037b22bd3df3c487a7b557f9867dc8177b85a13086cd259e3ad394382aa758c phase-0/tickets/P0-01-primary-research-refresh.md @@ -27,7 +27,7 @@ a27a9fc70ecc65ca8cbffb728bcb6da1690df49515b5fde28f98e6593bee63a6 phase-3/task-m 434147effe66cbae328a54182bf85625a25f0246be8fa3c75595eb0dca901894 phase-3/tickets/P3-00-workflow-baseline.md 902538b2ed4099776e9a8411f99a611cf5e864d2641ff40e3cdd96e10cc88d4b phase-3/tickets/P3-01-workflow-aware-tools.md 613d19df7075e45fc90c137ac0a52fa843e56d09ab381a46ee43a79507fd1795 phase-3/tickets/P3-02-independent-review.md -6f54b081f8882a654807e44df90bf6e4ca026fad073566f994d2f218302dd2fb references/README.md +4346f5ab221a1a9eac21fdff24315c7c49d0a192b45f5afece8da8f48b591872 references/README.md cc792c567a4900dd9f270ad5c7eec835d4b8bc6eed9353a31d093759476036c6 references/code-structure-outlines.md 4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh 004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh diff --git a/prompt-packs/mcp-server-next/README.md b/prompt-packs/mcp-server-next/README.md index 9557b09..4721bdf 100644 --- a/prompt-packs/mcp-server-next/README.md +++ b/prompt-packs/mcp-server-next/README.md @@ -5,8 +5,8 @@ Maintain the optional local-stdio MCP adapter without creating a second orchestrator. Phases 0 through 2 cover the completed research, shared read services, and bounded read-only server. Phase 3 is the remaining safe mutation -surface and is intentionally blocked until the complete workflow foundation -prompt pack finishes through canonical backlog task `WF-22`. +surface. The workflow foundation is complete through canonical backlog task +`WF-22`, so Phase 3 is now ready for a separately authorized execution. The workflow foundation is implemented first because MCP must wrap stable workflow, routing, lifecycle, approval, and receipt services. It must not invent @@ -15,8 +15,10 @@ parallel state machines or reinterpret workflow files itself. ## Source baseline The companion source archive is a filtered snapshot of the `agent-workflow` -checkout prepared 2026-07-24 at release 0.1.8. Current extracted source remains -authoritative when it differs from this pack. +checkout prepared from the pre-0.2.0 implementation. Current extracted source, +release 0.2.1, remains authoritative when it differs from historical pack text. +The 0.2.1 hardening pass corrected workflow replay, durable child authority, +projection recovery, evidence validation, and concurrent sealing boundaries. ## Phase map @@ -25,7 +27,7 @@ authoritative when it differs from this pack. | 0 | Research refresh, architecture review, and executable planning | completed | Accepted source baseline and primary-source evidence | | 1 | Reusable domain seams and typed read contracts | completed | Phase 0 accepted | | 2 | Local stdio read-only resource/server implementation and conformance | completed | Phase 1 accepted | -| 3 | Workflow-aware safe mutation adapter | blocked | `workflow-foundations-next` through `WF-22`, then Phase 2 accepted | +| 3 | Workflow-aware safe mutation adapter | ready | WF-22 and Phase 2 accepted; execute only as a separate MCP-003 scope | ## Universal delegation rules @@ -48,7 +50,7 @@ authoritative when it differs from this pack. ## How to execute remaining work -Do not execute Phase 3 until `WF-22` is marked done in `BACKLOG.md`. Then start +`WF-22` is done. Start with `phase-3/tickets/P3-00-workflow-baseline.md`, which must prove every MCP operation maps to a stable shared service. Validate with `agent-workflow pack validate` and run focused MCP/workflow tests before any diff --git a/prompt-packs/mcp-server-next/pack.yaml b/prompt-packs/mcp-server-next/pack.yaml index f293b4c..1078750 100644 --- a/prompt-packs/mcp-server-next/pack.yaml +++ b/prompt-packs/mcp-server-next/pack.yaml @@ -2,7 +2,7 @@ schema: agent-workflow/pack/v1 pack_id: "mcp-server-next" workflow: name: agent-workflow - minimum_version: "0.1.8" + minimum_version: "0.2.1" terminal_backend: tmux requires: - isolated_worktree diff --git a/prompt-packs/mcp-server-next/phase-0/README.md b/prompt-packs/mcp-server-next/phase-0/README.md index 92104ed..b03c3aa 100644 --- a/prompt-packs/mcp-server-next/phase-0/README.md +++ b/prompt-packs/mcp-server-next/phase-0/README.md @@ -20,7 +20,7 @@ No production writes. Research uses primary sources. Do not silently reopen approved decisions; record any challenge as a decision delta with evidence. Root README, `BACKLOG.md`, `docs/MCP_SERVER_DECISION.md`, MCP source/tests, -official SDK snapshot, and `references/`. +official SDK dependency record, and `references/`. Evidence contains the exact source baseline, research matrix, threat review, decision deltas, dependency graph, bounded tickets, and revised prompt pack. diff --git a/prompt-packs/mcp-server-next/references/README.md b/prompt-packs/mcp-server-next/references/README.md index 62bbc45..d02560f 100644 --- a/prompt-packs/mcp-server-next/references/README.md +++ b/prompt-packs/mcp-server-next/references/README.md @@ -9,7 +9,7 @@ Required source references in the companion archive: and durable-record authorities; - `schemas/`, `scripts/audit-release-assets.py`, and `MANIFEST.sha256` — release and wire-format gates; -- `src/agent_workflow/mcp/sdk/` — pinned official SDK source reference only; +- `src/agent_workflow/mcp/SDK_DEPENDENCY.md` — pinned SDK dependency and research provenance; - `docs/GLOBAL_AGENT_ROUTING.md` and `skills/agent-workflow-orchestrator/` — routing boundary and operational use. diff --git a/pyproject.toml b/pyproject.toml index c33bbc8..d20e7cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-workflow" -version = "0.1.12" +version = "0.2.1" description = "Terminal-first, observable workflow for bounded coding-agent delegation" readme = "README.md" requires-python = ">=3.11" @@ -40,14 +40,10 @@ include-package-data = false [tool.setuptools.packages.find] where = ["src"] -exclude = ["agent_workflow.mcp.sdk*"] [tool.setuptools.package-data] agent_workflow = ["assets/**/*"] -[tool.setuptools.exclude-package-data] -agent_workflow = ["mcp/sdk/**", "mcp/sdk/**/*"] - [tool.setuptools.data-files] "share/agent-workflow/schemas" = ["schemas/*.json"] diff --git a/schemas/execution-metrics.schema.json b/schemas/execution-metrics.schema.json index 24ba8e6..e21693a 100644 --- a/schemas/execution-metrics.schema.json +++ b/schemas/execution-metrics.schema.json @@ -40,6 +40,11 @@ "cached_input_tokens", "output_tokens", "provider_total_tokens", + "cache_write_input_tokens", + "reasoning_output_tokens", + "provider_billed_cost", + "local_estimated_cost", + "price_catalog_id", "cost", "currency", "elapsed_seconds", @@ -122,6 +127,24 @@ "steer_pending_count": { "type": "integer", "minimum": 0 + }, + "cache_write_input_tokens": { + "$ref": "#/$defs/nullableNumber" + }, + "reasoning_output_tokens": { + "$ref": "#/$defs/nullableNumber" + }, + "provider_billed_cost": { + "$ref": "#/$defs/nullableNumber" + }, + "local_estimated_cost": { + "$ref": "#/$defs/nullableNumber" + }, + "price_catalog_id": { + "type": [ + "string", + "null" + ] } }, "additionalProperties": false diff --git a/schemas/provider-evidence.schema.json b/schemas/provider-evidence.schema.json new file mode 100644 index 0000000..159da80 --- /dev/null +++ b/schemas/provider-evidence.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/provider-evidence/v1", + "type": "object", + "required": ["schema", "session_id", "executor", "stream_format", "created_at", "raw_events_path", "raw_events_sha256", "raw_event_bytes", "capture_limit_bytes", "capture_complete", "malformed_event_count", "classified_usage_count", "retry_of_run_id", "usage_complete", "incomplete_reasons", "usage_events", "aggregate"], + "properties": { + "schema": {"const": "agent-workflow/provider-evidence/v1"}, + "session_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "executor": {"type": ["string", "null"]}, + "stream_format": {"type": "string"}, + "created_at": {"type": "string"}, + "raw_events_path": {"const": "executor-events.jsonl"}, + "raw_events_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "raw_event_bytes": {"type": "integer", "minimum": 0}, + "capture_limit_bytes": {"type": "integer", "minimum": 1}, + "capture_complete": {"type": "boolean"}, + "malformed_event_count": {"type": "integer", "minimum": 0}, + "classified_usage_count": {"type": "integer", "minimum": 0}, + "retry_of_run_id": {"type": ["string", "null"], "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "usage_complete": {"type": "boolean"}, + "incomplete_reasons": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "usage_events": { + "type": "array", + "items": { + "type": "object", + "required": ["sequence", "event_sha256", "event_type", "mode", "usage"], + "properties": { + "sequence": {"type": "integer", "minimum": 1}, + "event_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "event_type": {"type": "string"}, + "mode": {"enum": ["delta", "cumulative", "terminal"]}, + "usage": {"$ref": "#/$defs/usage"} + }, + "additionalProperties": false + } + }, + "aggregate": {"$ref": "#/$defs/usage"} + }, + "$defs": { + "nullableNumber": {"type": ["integer", "number", "null"], "minimum": 0}, + "usage": { + "type": "object", + "required": ["input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "reasoning_output_tokens", "provider_total_tokens", "provider_billed_cost", "local_estimated_cost", "currency", "price_catalog_id"], + "properties": { + "input_tokens": {"$ref": "#/$defs/nullableNumber"}, + "cached_input_tokens": {"$ref": "#/$defs/nullableNumber"}, + "cache_write_input_tokens": {"$ref": "#/$defs/nullableNumber"}, + "output_tokens": {"$ref": "#/$defs/nullableNumber"}, + "reasoning_output_tokens": {"$ref": "#/$defs/nullableNumber"}, + "provider_total_tokens": {"$ref": "#/$defs/nullableNumber"}, + "provider_billed_cost": {"$ref": "#/$defs/nullableNumber"}, + "local_estimated_cost": {"$ref": "#/$defs/nullableNumber"}, + "currency": {"type": ["string", "null"]}, + "price_catalog_id": {"type": ["string", "null"]} + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/routing-advice.schema.json b/schemas/routing-advice.schema.json new file mode 100644 index 0000000..e544a2d --- /dev/null +++ b/schemas/routing-advice.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/routing-advice/v1", + "type": "object", + "required": ["schema", "recommendation", "explanation_codes", "enforced_selection", "policy_disagreements"], + "properties": { + "schema": {"const": "agent-workflow/routing-advice/v1"}, + "recommendation": {"$ref": "#/$defs/selection"}, + "enforced_selection": {"$ref": "#/$defs/selection"}, + "explanation_codes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "policy_disagreements": {"type": "array", "items": {"enum": ["agent_class", "executor", "model", "interactive"]}, "uniqueItems": true} + }, + "$defs": { + "selection": { + "type": "object", + "required": ["agent_class", "executor", "model", "interactive"], + "properties": { + "agent_class": {"type": "string", "minLength": 1}, + "executor": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "interactive": {"type": "boolean"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/run-provenance.schema.json b/schemas/run-provenance.schema.json index 00b81be..a6b7c4f 100644 --- a/schemas/run-provenance.schema.json +++ b/schemas/run-provenance.schema.json @@ -2,50 +2,262 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "agent-workflow/run-provenance/v1", "type": "object", - "required": ["schema", "session_id", "executor", "argv", "prompt_sha256", "source_revision", "environment", "started_at"], + "required": [ + "schema", + "session_id", + "executor", + "argv", + "prompt_sha256", + "source_revision", + "environment", + "started_at" + ], "properties": { - "schema": {"const": "agent-workflow/run-provenance/v1"}, - "session_id": {"type": "string"}, - "executor": {"type": ["string", "null"]}, - "agent_name": {"type": ["string", "null"]}, - "agent_class": {"type": ["string", "null"]}, - "argv": {"type": "array", "minItems": 1, "items": {"type": "string"}}, - "stream_format": {"enum": ["text", "codex-jsonl", "claude-stream-json"]}, - "executor_version": {"type": ["string", "null"]}, - "model": {"type": ["string", "null"]}, + "schema": { + "const": "agent-workflow/run-provenance/v1" + }, + "session_id": { + "type": "string" + }, + "executor": { + "type": [ + "string", + "null" + ] + }, + "agent_name": { + "type": [ + "string", + "null" + ] + }, + "agent_class": { + "type": [ + "string", + "null" + ] + }, + "argv": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "stream_format": { + "enum": [ + "text", + "codex-jsonl", + "claude-stream-json" + ] + }, + "executor_version": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, "model_policy": { "type": "object", - "required": ["no_go_authorized", "authorization_source"], + "required": [ + "no_go_authorized", + "authorization_source" + ], "properties": { - "no_go_authorized": {"type": "boolean"}, - "authorization_source": {"type": ["string", "null"]} + "no_go_authorized": { + "type": "boolean" + }, + "authorization_source": { + "type": [ + "string", + "null" + ] + } }, "additionalProperties": false }, - "prompt_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "launch_prompt_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "config_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, - "pack_manifest_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, - "source_revision": {"type": ["string", "null"]}, - "worktree": {"type": "string"}, - "environment": {"type": "object"}, - "budgets": {"type": "object"}, + "prompt_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "launch_prompt_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "config_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "pack_manifest_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "source_revision": { + "type": [ + "string", + "null" + ] + }, + "worktree": { + "type": "string" + }, + "environment": { + "type": "object" + }, + "retry_of_run_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "budgets": { + "type": "object" + }, "job_binding": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "properties": { - "path": {"type": "string"}, - "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "job_id": {"type": "string"} + "path": { + "type": "string" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "job_id": { + "type": "string" + } }, - "required": ["path", "sha256", "job_id"], + "required": [ + "path", + "sha256", + "job_id" + ], "additionalProperties": false }, - "evaluation_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, - "usage": {"type": ["object", "null"]}, - "started_at": {"type": "string"}, - "first_output_at": {"type": ["string", "null"]}, - "finished_at": {"type": ["string", "null"]}, - "exit_code": {"type": ["integer", "null"]} + "evaluation_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "usage": { + "type": [ + "object", + "null" + ] + }, + "started_at": { + "type": "string" + }, + "first_output_at": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "exit_code": { + "type": [ + "integer", + "null" + ] + }, + "workflow": { + "type": [ + "object", + "null" + ], + "properties": { + "workflow_id": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 1 + }, + "inputs_path": { + "type": [ + "string", + "null" + ] + }, + "inputs_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "routing": { + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "workflow_id", + "node_id", + "attempt", + "inputs_path", + "inputs_sha256", + "routing" + ], + "additionalProperties": false + }, + "provider_evidence": { + "type": [ + "object", + "null" + ], + "required": [ + "path", + "sha256", + "usage_complete", + "capture_complete" + ], + "properties": { + "path": { + "const": "provider-evidence.json" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "usage_complete": { + "type": "boolean" + }, + "capture_complete": { + "type": "boolean" + } + }, + "additionalProperties": false + } }, "additionalProperties": false } diff --git a/schemas/trial-evidence.schema.json b/schemas/trial-evidence.schema.json new file mode 100644 index 0000000..237ba5c --- /dev/null +++ b/schemas/trial-evidence.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/trial-evidence/v2", + "oneOf": [ + {"$ref": "#/$defs/trial"}, + {"$ref": "#/$defs/collection"} + ], + "$defs": { + "nullableNumber": {"type": ["integer", "number", "null"], "minimum": 0}, + "trial": { + "type": "object", + "required": ["schema", "trial_id", "run_path", "final_receipt_sha256", "provider_evidence_sha256", "raw_events_sha256", "verdict", "fixture_revision", "task_id", "base_revision", "prompt_sha256", "oracle_sha256", "acceptance_commands_sha256", "scope_policy_sha256", "scorer_versions_sha256", "sandbox", "budget_sha256", "repetition", "duration_seconds", "input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "reasoning_output_tokens", "provider_total_tokens", "tokens", "provider_billed_cost", "local_estimated_cost", "currency", "price_catalog_id", "retry_of_run_id", "retry_count", "errors", "steer_count", "steer_acknowledged_count", "source_artifacts"], + "properties": { + "schema": {"const": "agent-workflow/trial-evidence/v2"}, + "trial_id": {"type": "string", "minLength": 1}, + "run_path": {"type": "string", "minLength": 1}, + "final_receipt_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "provider_evidence_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "raw_events_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "verdict": {"enum": ["pass", "fail", "invalid"]}, + "fixture_revision": {}, "task_id": {}, "base_revision": {}, + "prompt_sha256": {}, "oracle_sha256": {}, "acceptance_commands_sha256": {}, + "scope_policy_sha256": {}, "scorer_versions_sha256": {}, "sandbox": {}, + "budget_sha256": {}, "repetition": {}, + "duration_seconds": {"$ref": "#/$defs/nullableNumber"}, + "input_tokens": {"$ref": "#/$defs/nullableNumber"}, + "cached_input_tokens": {"$ref": "#/$defs/nullableNumber"}, + "cache_write_input_tokens": {"$ref": "#/$defs/nullableNumber"}, + "output_tokens": {"$ref": "#/$defs/nullableNumber"}, + "reasoning_output_tokens": {"$ref": "#/$defs/nullableNumber"}, + "provider_total_tokens": {"$ref": "#/$defs/nullableNumber"}, + "tokens": {"$ref": "#/$defs/nullableNumber"}, + "provider_billed_cost": {"$ref": "#/$defs/nullableNumber"}, + "local_estimated_cost": {"$ref": "#/$defs/nullableNumber"}, + "currency": {"type": ["string", "null"]}, + "price_catalog_id": {"type": ["string", "null"]}, + "retry_of_run_id": {"type": ["string", "null"]}, + "retry_count": {"type": ["integer", "null"], "minimum": 0}, + "errors": {"type": "array"}, + "steer_count": {"type": ["integer", "null"], "minimum": 0}, + "steer_acknowledged_count": {"type": ["integer", "null"], "minimum": 0}, + "source_artifacts": {"type": "object", "additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{64}$"}} + }, + "additionalProperties": false + }, + "collection": { + "type": "object", + "required": ["schema", "collected_at", "trials"], + "properties": { + "schema": {"const": "agent-workflow/trial-evidence/v2"}, + "collected_at": {"type": "string"}, + "trials": {"type": "array", "items": {"$ref": "#/$defs/trial"}} + }, + "additionalProperties": false + } + } +} diff --git a/schemas/workflow-event.schema.json b/schemas/workflow-event.schema.json new file mode 100644 index 0000000..0e5bbed --- /dev/null +++ b/schemas/workflow-event.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-event/v1", + "type": "object", + "required": ["schema", "sequence", "timestamp", "workflow_id", "kind", "node_id", "actor", "reason", "snapshot_sha256"], + "properties": { + "schema": {"const": "agent-workflow/workflow-event/v1"}, + "sequence": {"type": "integer", "minimum": 1}, + "timestamp": {"type": "string"}, + "workflow_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "kind": {"enum": ["node-bound", "node-transition"]}, + "node_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "actor": {"type": "string", "minLength": 1}, + "reason": {"type": "string", "minLength": 1}, + "snapshot_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "previous_state": { + "type": ["string", "null"], + "enum": [null, "blocked", "eligible", "running", "completed", "failed", "recoverable"] + }, + "next_state": { + "type": ["string", "null"], + "enum": [null, "blocked", "eligible", "running", "completed", "failed", "recoverable"] + }, + "binding": { + "type": ["object", "null"], + "required": ["run_id", "attempt", "bound_at", "current"], + "properties": { + "run_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "attempt": {"type": "integer", "minimum": 1}, + "retry_of_run_id": { + "type": ["string", "null"], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "bound_at": {"type": "string"}, + "current": {"type": "boolean"} + }, + "additionalProperties": false + }, + "details": {"type": ["object", "null"]} + }, + "allOf": [ + { + "if": {"properties": {"kind": {"const": "node-bound"}}}, + "then": {"required": ["binding"], "properties": {"previous_state": {"const": null}, "next_state": {"const": null}}} + }, + { + "if": {"properties": {"kind": {"const": "node-transition"}}}, + "then": {"required": ["previous_state", "next_state"], "properties": {"binding": {"const": null}}} + } + ], + "additionalProperties": false +} diff --git a/schemas/workflow-input-bindings.schema.json b/schemas/workflow-input-bindings.schema.json new file mode 100644 index 0000000..6ae10a2 --- /dev/null +++ b/schemas/workflow-input-bindings.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-input-bindings/v1", + "type": "object", + "required": ["schema", "workflow_id", "snapshot_sha256", "node_id", "attempt", "created_at", "bindings"], + "properties": { + "schema": {"const": "agent-workflow/workflow-input-bindings/v1"}, + "workflow_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "snapshot_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "node_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "attempt": {"type": "integer", "minimum": 1}, + "created_at": {"type": "string"}, + "bindings": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "required": ["name", "source_node_id", "source_run_id", "pointer", "required", "present", "value", "value_size_bytes", "value_sha256", "source_result_sha256", "source_final_receipt_sha256"], + "properties": { + "name": {"type": "string"}, + "source_node_id": {"type": "string"}, + "source_run_id": {"type": "string"}, + "pointer": {"type": "string"}, + "required": {"type": "boolean"}, + "present": {"type": "boolean"}, + "value": {}, + "value_size_bytes": {"type": "integer", "minimum": 0, "maximum": 1048576}, + "value_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "source_result_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "source_final_receipt_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/workflow-node-binding.schema.json b/schemas/workflow-node-binding.schema.json new file mode 100644 index 0000000..09e9252 --- /dev/null +++ b/schemas/workflow-node-binding.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-node-binding/v1", + "type": "object", + "required": ["schema", "workflow_id", "node_id", "run_id", "attempt", "bound_at", "current"], + "properties": { + "schema": {"const": "agent-workflow/workflow-node-binding/v1"}, + "workflow_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "node_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "run_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "attempt": {"type": "integer", "minimum": 1}, + "retry_of_run_id": { + "type": ["string", "null"], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "bound_at": {"type": "string"}, + "current": {"type": "boolean"} + }, + "additionalProperties": false +} diff --git a/schemas/workflow-node-result.schema.json b/schemas/workflow-node-result.schema.json new file mode 100644 index 0000000..b7fecc5 --- /dev/null +++ b/schemas/workflow-node-result.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-node-result/v1", + "type": "object", + "required": [ + "schema", + "workflow_id", + "action", + "result" + ], + "properties": { + "schema": { + "const": "agent-workflow/workflow-node-result/v1" + }, + "workflow_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "action": { + "enum": [ + "validate", + "start", + "status", + "resume", + "seal", + "verify" + ] + }, + "result": { + "type": "object" + }, + "snapshot_path": { + "type": [ + "string", + "null" + ] + }, + "events_path": { + "type": [ + "string", + "null" + ] + }, + "status_path": { + "type": [ + "string", + "null" + ] + }, + "run_path": { + "type": [ + "string", + "null" + ] + }, + "started": { + "type": [ + "boolean", + "null" + ] + }, + "resumed": { + "type": [ + "boolean", + "null" + ] + }, + "scheduled": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "receipt_path": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} diff --git a/schemas/workflow-receipt.schema.json b/schemas/workflow-receipt.schema.json new file mode 100644 index 0000000..40a065c --- /dev/null +++ b/schemas/workflow-receipt.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-receipt/v1", + "type": "object", + "required": ["schema", "workflow_id", "created_at", "workflow_state", "snapshot_sha256", "snapshot_file_sha256", "events_sha256", "event_count", "node_count", "nodes"], + "properties": { + "schema": {"const": "agent-workflow/workflow-receipt/v1"}, + "workflow_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "created_at": {"type": "string", "minLength": 1}, + "workflow_state": {"enum": ["completed", "failed"]}, + "snapshot_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "snapshot_file_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "events_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "event_count": {"type": "integer", "minimum": 0}, + "node_count": {"type": "integer", "minimum": 1}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["node_id", "kind", "state", "terminal_reason", "attempt", "retry_of_run_id", "binding_history", "input_binding_sha256", "approval_receipt_sha256", "child_run_id", "child_final_receipt_sha256", "child_completion_sha256"], + "properties": { + "node_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "kind": {"enum": ["task", "approval"]}, + "state": {"enum": ["completed", "failed"]}, + "terminal_reason": {"type": ["string", "null"]}, + "attempt": {"type": ["integer", "null"], "minimum": 1}, + "retry_of_run_id": {"type": ["string", "null"], "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "binding_history": { + "type": "array", + "items": { + "type": "object", + "required": ["run_id", "attempt", "retry_of_run_id", "bound_at"], + "properties": { + "run_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "attempt": {"type": "integer", "minimum": 1}, + "retry_of_run_id": {"type": ["string", "null"], "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "bound_at": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + } + }, + "input_binding_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "approval_receipt_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "child_run_id": {"type": ["string", "null"], "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"}, + "child_final_receipt_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "child_completion_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/workflow-run.schema.json b/schemas/workflow-run.schema.json new file mode 100644 index 0000000..111056e --- /dev/null +++ b/schemas/workflow-run.schema.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-run/v1", + "type": "object", + "required": [ + "schema", + "workflow_id", + "snapshot_sha256", + "snapshot_path", + "events_path", + "status_path", + "status" + ], + "properties": { + "schema": { + "const": "agent-workflow/workflow-run/v1" + }, + "workflow_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "snapshot_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "snapshot_path": { + "type": "string", + "minLength": 1 + }, + "events_path": { + "type": "string", + "minLength": 1 + }, + "status_path": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "object", + "required": [ + "schema", + "workflow_id", + "snapshot_sha256", + "event_count", + "workflow_state", + "nodes" + ], + "properties": { + "schema": { + "const": "agent-workflow/workflow-status/v1" + }, + "workflow_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "snapshot_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "event_count": { + "type": "integer", + "minimum": 0 + }, + "workflow_state": { + "enum": [ + "blocked", + "eligible", + "running", + "completed", + "failed", + "recoverable" + ] + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "node_id", + "state", + "run_id", + "attempt", + "retry_of_run_id", + "bound_at", + "terminal_reason" + ], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "state": { + "enum": [ + "blocked", + "eligible", + "running", + "completed", + "failed", + "recoverable" + ] + }, + "run_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "retry_of_run_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "bound_at": { + "type": [ + "string", + "null" + ] + }, + "terminal_reason": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "task", + "approval" + ] + }, + "approval_receipt_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "input_binding_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/workflow-snapshot.schema.json b/schemas/workflow-snapshot.schema.json new file mode 100644 index 0000000..09a39d8 --- /dev/null +++ b/schemas/workflow-snapshot.schema.json @@ -0,0 +1,193 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-snapshot/v1", + "type": "object", + "required": [ + "schema", + "workflow_id", + "pack_id", + "pack_manifest_sha256", + "nodes" + ], + "properties": { + "schema": { + "const": "agent-workflow/workflow-snapshot/v1" + }, + "workflow_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "pack_id": { + "type": "string", + "minLength": 1 + }, + "pack_manifest_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "node_id", + "dependencies" + ], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "kind": { + "enum": [ + "task", + "approval" + ] + }, + "ticket_id": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "tier": { + "type": [ + "string", + "null" + ] + }, + "pack_id": { + "type": [ + "string", + "null" + ] + }, + "prompt_path": { + "type": "string", + "minLength": 1 + }, + "approval_for": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "agent_class": { + "type": [ + "string", + "null" + ] + }, + "executor": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "interactive": { + "type": [ + "boolean", + "null" + ] + }, + "allow_no_go_model": { + "type": [ + "boolean", + "null" + ] + }, + "routing": { + "type": "object" + }, + "input_bindings": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "type": "object", + "required": [ + "source_node_id", + "pointer", + "required", + "max_bytes" + ], + "properties": { + "source_node_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "pointer": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 1048576 + } + }, + "additionalProperties": false + } + }, + "dependencies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "task" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "required": [ + "session_id", + "prompt_path" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "const": "approval" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "required": [ + "approval_for" + ] + } + } + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/workflow-status.schema.json b/schemas/workflow-status.schema.json new file mode 100644 index 0000000..783c644 --- /dev/null +++ b/schemas/workflow-status.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/workflow-status/v1", + "type": "object", + "required": [ + "schema", + "workflow_id", + "snapshot_sha256", + "event_count", + "workflow_state", + "nodes" + ], + "properties": { + "schema": { + "const": "agent-workflow/workflow-status/v1" + }, + "workflow_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "snapshot_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "event_count": { + "type": "integer", + "minimum": 0 + }, + "workflow_state": { + "enum": [ + "blocked", + "eligible", + "running", + "completed", + "failed", + "recoverable" + ] + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "node_id", + "state", + "run_id", + "attempt", + "retry_of_run_id", + "bound_at", + "terminal_reason" + ], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "state": { + "enum": [ + "blocked", + "eligible", + "running", + "completed", + "failed", + "recoverable" + ] + }, + "run_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "attempt": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "retry_of_run_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$" + }, + "bound_at": { + "type": [ + "string", + "null" + ] + }, + "terminal_reason": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "enum": [ + "task", + "approval" + ] + }, + "approval_receipt_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + }, + "input_binding_sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/scripts/audit-release-assets.py b/scripts/audit-release-assets.py index 5d1aea7..43e72b6 100755 --- a/scripts/audit-release-assets.py +++ b/scripts/audit-release-assets.py @@ -37,43 +37,24 @@ EXCLUDED_FILES = {".coverage", "MANIFEST.sha256"} -def release_files() -> tuple[Path, ...]: +def release_files(root: Path = ROOT) -> tuple[Path, ...]: files: list[Path] = [] - for path in ROOT.rglob("*"): + for path in root.rglob("*"): if not path.is_file() or path.is_symlink(): continue - rel = path.relative_to(ROOT) + rel = path.relative_to(root) if any( part in EXCLUDED_DIRS or part.endswith(".egg-info") for part in rel.parts[:-1] ): continue + if any(part == ".git" for part in rel.parts): + continue if rel.name in EXCLUDED_FILES or rel.suffix in {".pyc", ".sha256", ".zst"}: continue files.append(path) return tuple(sorted(files)) - -parser = argparse.ArgumentParser() -parser.add_argument( - "--write-manifest", - action="store_true", - help="regenerate the repository release manifest before validating it", -) -args = parser.parse_args() -RELEASE_FILES = release_files() - -if args.write_manifest: - manifest_lines = [ - f"{hashlib.sha256(path.read_bytes()).hexdigest()} " - f"{path.relative_to(ROOT).as_posix()}" - for path in RELEASE_FILES - ] - (ROOT / "MANIFEST.sha256").write_text( - "\n".join(manifest_lines) + "\n", encoding="utf-8" - ) - - def fail(message: str) -> None: errors.append(message) @@ -100,231 +81,271 @@ def parse_frontmatter(path: Path) -> dict[str, str]: return data -# Basic text integrity and placeholder policy. -for path in RELEASE_FILES: - data = path.read_bytes() - rel = path.relative_to(ROOT) - if b"\x00" in data: - fail(f"{rel}: contains NUL bytes") - if b"\r\n" in data: - fail(f"{rel}: contains CRLF line endings") - try: - text = data.decode("utf-8") - except UnicodeDecodeError: - continue - placeholders = PLACEHOLDER_RE.findall(text) - if placeholders and not ( - str(rel).startswith("templates/") - or str(rel).startswith("src/agent_workflow/assets/") - or rel == Path("src/agent_workflow/pack.py") - or rel == Path("scripts/audit-release-assets.py") - ): - fail(f"{rel}: unresolved template placeholders outside template assets: {sorted(set(placeholders))}") +def main(argv: list[str] | None = None) -> int: + global errors + errors = [] + parser = argparse.ArgumentParser() + parser.add_argument( + "--write-manifest", + action="store_true", + help="regenerate the repository release manifest before validating it", + ) + args = parser.parse_args(argv) + release_files_root = ROOT + release_files_list = release_files(release_files_root) -# Skills must be discoverable and correctly named. -skill_names: set[str] = set() -for skill_dir in sorted((ROOT / "skills").iterdir()): - if not skill_dir.is_dir(): - continue - path = skill_dir / "SKILL.md" - if not path.is_file(): - fail(f"skills/{skill_dir.name}: missing SKILL.md") - continue - metadata = parse_frontmatter(path) - name = metadata.get("name", "") - description = metadata.get("description", "") - if name != skill_dir.name: - fail(f"{path.relative_to(ROOT)}: frontmatter name {name!r} must match directory {skill_dir.name!r}") - if not description or len(description) < 20: - fail(f"{path.relative_to(ROOT)}: description is missing or too vague") - if name in skill_names: - fail(f"{path.relative_to(ROOT)}: duplicate skill name {name!r}") - skill_names.add(name) + if args.write_manifest: + manifest_lines = [ + f"{hashlib.sha256(path.read_bytes()).hexdigest()} " + f"{path.relative_to(ROOT).as_posix()}" + for path in release_files_list + ] + (ROOT / "MANIFEST.sha256").write_text( + "\n".join(manifest_lines) + "\n", encoding="utf-8" + ) -# JSON and JSON Schema syntax. -for path in (path for path in RELEASE_FILES if path.suffix == ".json"): - try: - value = json.loads(path.read_text(encoding="utf-8")) - except Exception as exc: - fail(f"{path.relative_to(ROOT)}: invalid JSON: {exc}") - continue - if path.parent.name == "schemas": + # Basic text integrity and placeholder policy. + for path in release_files_list: + data = path.read_bytes() + rel = path.relative_to(ROOT) + if b"\x00" in data: + fail(f"{rel}: contains NUL bytes") + if b"\r\n" in data: + fail(f"{rel}: contains CRLF line endings") try: - import jsonschema - jsonschema.Draft202012Validator.check_schema(value) - except ImportError: - pass - except Exception as exc: - fail(f"{path.relative_to(ROOT)}: invalid JSON Schema: {exc}") + text = data.decode("utf-8") + except UnicodeDecodeError: + continue + placeholders = PLACEHOLDER_RE.findall(text) + if placeholders and not ( + str(rel).startswith("templates/") + or str(rel).startswith("src/agent_workflow/assets/") + or rel == Path("src/agent_workflow/pack.py") + or rel == Path("scripts/audit-release-assets.py") + ): + fail( + f"{rel}: unresolved template placeholders outside template assets: " + f"{sorted(set(placeholders))}" + ) -# TOML files and documented TOML examples must parse. -for path in (path for path in RELEASE_FILES if path.suffix == ".toml"): - try: - tomllib.loads(path.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: - fail(f"{path.relative_to(ROOT)}: invalid TOML: {exc}") -readme = (ROOT / "README.md").read_text(encoding="utf-8") -for index, block in enumerate(re.findall(r"```toml\n(.*?)```", readme, re.DOTALL), 1): - try: - tomllib.loads(block) - except tomllib.TOMLDecodeError as exc: - fail(f"README.md: TOML block {index} is invalid: {exc}") + # Skills must be discoverable and correctly named. + skill_names: set[str] = set() + for skill_dir in sorted((ROOT / "skills").iterdir()): + if not skill_dir.is_dir(): + continue + path = skill_dir / "SKILL.md" + if not path.is_file(): + fail(f"skills/{skill_dir.name}: missing SKILL.md") + continue + metadata = parse_frontmatter(path) + name = metadata.get("name", "") + description = metadata.get("description", "") + if name != skill_dir.name: + fail( + f"{path.relative_to(ROOT)}: frontmatter name {name!r} must match " + f"directory {skill_dir.name!r}" + ) + if not description or len(description) < 20: + fail(f"{path.relative_to(ROOT)}: description is missing or too vague") + if name in skill_names: + fail(f"{path.relative_to(ROOT)}: duplicate skill name {name!r}") + skill_names.add(name) -# YAML syntax, including unexpanded templates. -try: - import yaml -except ImportError: - yaml = None -if yaml is not None: - for path in (path for path in RELEASE_FILES if path.suffix == ".yaml"): + # JSON and JSON Schema syntax. + for path in (path for path in release_files_list if path.suffix == ".json"): try: - yaml.safe_load(path.read_text(encoding="utf-8")) + value = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: - fail(f"{path.relative_to(ROOT)}: invalid YAML: {exc}") + fail(f"{path.relative_to(ROOT)}: invalid JSON: {exc}") + continue + if path.parent.name == "schemas": + try: + import jsonschema -# Version consistency in authoritative metadata and runtime surfaces. -version_locations = { - ROOT / "VERSION": EXPECTED_VERSION, - ROOT / "pyproject.toml": f'version = "{EXPECTED_VERSION}"', - ROOT / "agent-workflow.yaml": f"version: {EXPECTED_VERSION}", - ROOT / "src/agent_workflow/__init__.py": f'__version__ = "{EXPECTED_VERSION}"', - ROOT / "src/agent_workflow/cli.py": f'%(prog)s {EXPECTED_VERSION}', - ROOT / "src/agent_workflow/doctor.py": f'"version": "{EXPECTED_VERSION}"', -} -for path, needle in version_locations.items(): - if needle not in path.read_text(encoding="utf-8"): - fail(f"{path.relative_to(ROOT)}: missing expected version marker {needle!r}") + jsonschema.Draft202012Validator.check_schema(value) + except ImportError: + pass + except Exception as exc: + fail(f"{path.relative_to(ROOT)}: invalid JSON Schema: {exc}") -# Portable copies must not drift from their canonical source. -portable_scripts = { - "archive-prompt-pack.sh", - "check-delegation.sh", - "create-ticket-worktree.sh", - "foreground-delegation.sh", - "launch-delegation.sh", - "restart-delegation.sh", - "stop-delegation.sh", - "validate-prompt-pack.sh", -} -for canonical in sorted( - path for path in (ROOT / "scripts").glob("*.sh") - if path.name in portable_scripts -): - for mirror_root in [ - ROOT / "templates/prompt-pack/scripts", - ROOT / "src/agent_workflow/assets/prompt-pack-root/scripts", - ]: - mirror = mirror_root / canonical.name - if not mirror.is_file(): - fail(f"{mirror.relative_to(ROOT)}: missing mirror of scripts/{canonical.name}") - elif mirror.read_bytes() != canonical.read_bytes(): - fail(f"{mirror.relative_to(ROOT)}: differs from canonical scripts/{canonical.name}") + # TOML files and documented TOML examples must parse. + for path in (path for path in release_files_list if path.suffix == ".toml"): + try: + tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + fail(f"{path.relative_to(ROOT)}: invalid TOML: {exc}") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + for index, block in enumerate(re.findall(r"```toml\n(.*?)```", readme, re.DOTALL), 1): + try: + tomllib.loads(block) + except tomllib.TOMLDecodeError as exc: + fail(f"README.md: TOML block {index} is invalid: {exc}") -mirror_groups = { - ROOT / "EXECUTION_PROTOCOL.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/EXECUTION_PROTOCOL.md", - ROOT / "examples/three-phase-pack/EXECUTION_PROTOCOL.md", - ], - ROOT / "DELEGATION_RUNBOOK.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/DELEGATION_RUNBOOK.md", - ROOT / "examples/three-phase-pack/DELEGATION_RUNBOOK.md", - ], - ROOT / "templates/prompt-pack/ROOT_README.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/README.md", - ], - ROOT / "templates/prompt-pack/pack.yaml": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/pack.yaml", - ], - ROOT / "templates/prompt-pack/references-README.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/references/README.md", - ], - ROOT / "templates/prompt-pack/CODE_STRUCTURE_OUTLINES.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/references/code-structure-outlines.md", - ], - ROOT / "templates/prompt-pack/PHASE_README.md": [ - ROOT / "src/agent_workflow/assets/phase/README.md", - ], - ROOT / "templates/prompt-pack/MASTER_IMPLEMENTATION_PROMPT.md": [ - ROOT / "src/agent_workflow/assets/phase/MASTER_IMPLEMENTATION_PROMPT.md", - ], - ROOT / "templates/prompt-pack/task-manifest.yaml": [ - ROOT / "src/agent_workflow/assets/phase/task-manifest.yaml", - ], - ROOT / "templates/prompt-pack/TICKET_PROMPT.md": [ - ROOT / "src/agent_workflow/assets/phase/tickets/P{{PHASE_NUMBER}}-00-baseline-and-preflight.md", - ], - ROOT / "templates/TICKET_COMPLETION.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/templates/TICKET_COMPLETION.md", - ], - ROOT / "templates/PHASE_GATE_REPORT.md": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/templates/PHASE_GATE_REPORT.md", - ], - ROOT / "templates/source-baseline.example.json": [ - ROOT / "src/agent_workflow/assets/prompt-pack-root/templates/source-baseline.example.json", - ], -} -for canonical, mirrors in mirror_groups.items(): - for mirror in mirrors: - if not mirror.is_file(): - fail(f"{mirror.relative_to(ROOT)}: missing mirror of {canonical.relative_to(ROOT)}") - elif mirror.read_bytes() != canonical.read_bytes(): - fail( - f"{mirror.relative_to(ROOT)}: differs from canonical " - f"{canonical.relative_to(ROOT)}" - ) + # YAML syntax, including unexpanded templates. + try: + import yaml + except ImportError: + yaml = None + if yaml is not None: + for path in (path for path in release_files_list if path.suffix == ".yaml"): + try: + yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception as exc: + fail(f"{path.relative_to(ROOT)}: invalid YAML: {exc}") -# Shell entrypoints must be executable. -for path in [ROOT / "install.sh", ROOT / "uninstall.sh", ROOT / "bin/agent-workflow", *sorted((ROOT / "scripts").glob("*.sh"))]: - if not path.stat().st_mode & stat.S_IXUSR: - fail(f"{path.relative_to(ROOT)}: is not executable") + # Version consistency in authoritative metadata and runtime surfaces. + version_locations = { + ROOT / "VERSION": EXPECTED_VERSION, + ROOT / "pyproject.toml": f'version = "{EXPECTED_VERSION}"', + ROOT / "agent-workflow.yaml": f"version: {EXPECTED_VERSION}", + ROOT / "src/agent_workflow/__init__.py": f'__version__ = "{EXPECTED_VERSION}"', + ROOT / "src/agent_workflow/cli.py": f'%(prog)s {EXPECTED_VERSION}', + ROOT / "src/agent_workflow/doctor.py": f'"version": "{EXPECTED_VERSION}"', + } + for path, needle in version_locations.items(): + if needle not in path.read_text(encoding="utf-8"): + fail(f"{path.relative_to(ROOT)}: missing expected version marker {needle!r}") -# Local Markdown links must resolve. -for path in (path for path in RELEASE_FILES if path.suffix == ".md"): - text = path.read_text(encoding="utf-8") - for target in LINK_RE.findall(text): - target = target.strip().split()[0].strip("<>") - if not target or target.startswith(("http://", "https://", "mailto:", "#")): - continue - target = target.split("#", 1)[0] - if not target: - continue - resolved = (path.parent / target).resolve() - try: - resolved.relative_to(ROOT.resolve()) - except ValueError: - fail(f"{path.relative_to(ROOT)}: local link escapes repository: {target}") - continue - if not resolved.exists(): - fail(f"{path.relative_to(ROOT)}: broken local link: {target}") + # Portable copies must not drift from their canonical source. + portable_scripts = { + "archive-prompt-pack.sh", + "check-delegation.sh", + "create-ticket-worktree.sh", + "foreground-delegation.sh", + "launch-delegation.sh", + "restart-delegation.sh", + "stop-delegation.sh", + "validate-prompt-pack.sh", + } + for canonical in sorted( + path for path in (ROOT / "scripts").glob("*.sh") + if path.name in portable_scripts + ): + for mirror_root in [ + ROOT / "templates/prompt-pack/scripts", + ROOT / "src/agent_workflow/assets/prompt-pack-root/scripts", + ]: + mirror = mirror_root / canonical.name + if not mirror.is_file(): + fail(f"{mirror.relative_to(ROOT)}: missing mirror of scripts/{canonical.name}") + elif mirror.read_bytes() != canonical.read_bytes(): + fail(f"{mirror.relative_to(ROOT)}: differs from canonical scripts/{canonical.name}") -# Manifest must cover every regular non-symlink file except itself. -manifest = ROOT / "MANIFEST.sha256" -if manifest.is_file(): - listed: dict[str, str] = {} - for number, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), 1): - digest, sep, rel = line.partition(" ") - if not sep or len(digest) != 64: - fail(f"MANIFEST.sha256:{number}: malformed line") - continue - if rel in listed: - fail(f"MANIFEST.sha256:{number}: duplicate path {rel}") - listed[rel] = digest - actual = { - path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() - for path in RELEASE_FILES + mirror_groups = { + ROOT / "EXECUTION_PROTOCOL.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/EXECUTION_PROTOCOL.md", + ROOT / "examples/three-phase-pack/EXECUTION_PROTOCOL.md", + ], + ROOT / "DELEGATION_RUNBOOK.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/DELEGATION_RUNBOOK.md", + ROOT / "examples/three-phase-pack/DELEGATION_RUNBOOK.md", + ], + ROOT / "templates/prompt-pack/ROOT_README.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/README.md", + ], + ROOT / "templates/prompt-pack/pack.yaml": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/pack.yaml", + ], + ROOT / "templates/prompt-pack/references-README.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/references/README.md", + ], + ROOT / "templates/prompt-pack/CODE_STRUCTURE_OUTLINES.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/references/code-structure-outlines.md", + ], + ROOT / "templates/prompt-pack/PHASE_README.md": [ + ROOT / "src/agent_workflow/assets/phase/README.md", + ], + ROOT / "templates/prompt-pack/MASTER_IMPLEMENTATION_PROMPT.md": [ + ROOT / "src/agent_workflow/assets/phase/MASTER_IMPLEMENTATION_PROMPT.md", + ], + ROOT / "templates/prompt-pack/task-manifest.yaml": [ + ROOT / "src/agent_workflow/assets/phase/task-manifest.yaml", + ], + ROOT / "templates/prompt-pack/TICKET_PROMPT.md": [ + ROOT / "src/agent_workflow/assets/phase/tickets/P{{PHASE_NUMBER}}-00-baseline-and-preflight.md", + ], + ROOT / "templates/TICKET_COMPLETION.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/templates/TICKET_COMPLETION.md", + ], + ROOT / "templates/PHASE_GATE_REPORT.md": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/templates/PHASE_GATE_REPORT.md", + ], + ROOT / "templates/source-baseline.example.json": [ + ROOT / "src/agent_workflow/assets/prompt-pack-root/templates/source-baseline.example.json", + ], } - for rel in sorted(actual.keys() - listed.keys()): - fail(f"MANIFEST.sha256: missing file {rel}") - for rel in sorted(listed.keys() - actual.keys()): - fail(f"MANIFEST.sha256: lists nonexistent file {rel}") - for rel in sorted(actual.keys() & listed.keys()): - if actual[rel] != listed[rel]: - fail(f"MANIFEST.sha256: checksum mismatch for {rel}") -else: - fail("MANIFEST.sha256: missing") + for canonical, mirrors in mirror_groups.items(): + for mirror in mirrors: + if not mirror.is_file(): + fail(f"{mirror.relative_to(ROOT)}: missing mirror of {canonical.relative_to(ROOT)}") + elif mirror.read_bytes() != canonical.read_bytes(): + fail( + f"{mirror.relative_to(ROOT)}: differs from canonical " + f"{canonical.relative_to(ROOT)}" + ) + + # Shell entrypoints must be executable. + for path in [ + ROOT / "install.sh", + ROOT / "uninstall.sh", + ROOT / "bin/agent-workflow", + *sorted((ROOT / "scripts").glob("*.sh")), + ]: + if not path.stat().st_mode & stat.S_IXUSR: + fail(f"{path.relative_to(ROOT)}: is not executable") + + # Local Markdown links must resolve. + for path in (path for path in release_files_list if path.suffix == ".md"): + text = path.read_text(encoding="utf-8") + for target in LINK_RE.findall(text): + target = target.strip().split()[0].strip("<>") + if not target or target.startswith(("http://", "https://", "mailto:", "#")): + continue + target = target.split("#", 1)[0] + if not target: + continue + resolved = (path.parent / target).resolve() + try: + resolved.relative_to(ROOT.resolve()) + except ValueError: + fail(f"{path.relative_to(ROOT)}: local link escapes repository: {target}") + continue + if not resolved.exists(): + fail(f"{path.relative_to(ROOT)}: broken local link: {target}") + + # Manifest must cover every regular non-symlink file except itself. + manifest = ROOT / "MANIFEST.sha256" + if manifest.is_file(): + listed: dict[str, str] = {} + for number, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), 1): + digest, sep, rel = line.partition(" ") + if not sep or len(digest) != 64: + fail(f"MANIFEST.sha256:{number}: malformed line") + continue + if rel in listed: + fail(f"MANIFEST.sha256:{number}: duplicate path {rel}") + listed[rel] = digest + actual = { + path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in release_files_list + } + for rel in sorted(actual.keys() - listed.keys()): + fail(f"MANIFEST.sha256: missing file {rel}") + for rel in sorted(listed.keys() - actual.keys()): + fail(f"MANIFEST.sha256: lists nonexistent file {rel}") + for rel in sorted(actual.keys() & listed.keys()): + if actual[rel] != listed[rel]: + fail(f"MANIFEST.sha256: checksum mismatch for {rel}") + else: + fail("MANIFEST.sha256: missing") + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print("release assets: valid") + return 0 + -if errors: - for error in errors: - print(f"ERROR: {error}", file=sys.stderr) - raise SystemExit(1) -print("release assets: valid") +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/agent-workflow-orchestrator/SKILL.md b/skills/agent-workflow-orchestrator/SKILL.md index 0867d30..0e1f6ab 100644 --- a/skills/agent-workflow-orchestrator/SKILL.md +++ b/skills/agent-workflow-orchestrator/SKILL.md @@ -69,3 +69,22 @@ Terminal text is operational context, not sealed proof. Inspect the authoritativ - [`docs/PROMPT_PACK_STANDARD.md`](../../docs/PROMPT_PACK_STANDARD.md) - [`EXECUTION_PROTOCOL.md`](../../EXECUTION_PROTOCOL.md) - [`DELEGATION_RUNBOOK.md`](../../DELEGATION_RUNBOOK.md) + +## Workflow graphs + +```bash +agent-workflow workflow validate workflow.json +agent-workflow workflow start workflow-run workflow.json +agent-workflow workflow status workflow-run workflow.json +agent-workflow workflow resume workflow-run workflow.json +agent-workflow workflow seal workflow-run workflow.json +agent-workflow workflow verify workflow-run workflow.json +``` + +Use only the three authorized `workflow template` shapes. Treat the normalized snapshot and append-only workflow journal as authority. Approval gates require canonical immutable lifecycle receipts. Result bindings may consume only bounded JSON Pointer values from sealed ancestor results. Do not launch workflow children outside the scheduler's canonical session service. + +## Provider and MCP boundaries + +A structured run is comparison-ready only when its bounded raw executor stream, provider evidence, metrics, completion, and final receipt are sealed and complete. Do not infer tokens or cost from prose/logs, add cached or reasoning details twice, or combine provider-billed and locally estimated cost. + +The current `agent-workflow-mcp` adapter is local stdio and read-only. Do not claim planned MCP-003 mutation tools exist. Any future MCP mutation must reuse shared services and durable idempotency. diff --git a/skills/delegated-implementation/SKILL.md b/skills/delegated-implementation/SKILL.md index 38c6936..17cb8fb 100644 --- a/skills/delegated-implementation/SKILL.md +++ b/skills/delegated-implementation/SKILL.md @@ -27,3 +27,9 @@ A host-native child process or subagent is not an `agent-workflow` run unless an ## Stop conditions Stop rather than guess when current source would make the ticket destructive, a required dependency is absent, a migration cannot be made recoverable, or secrets/real target data would be exposed. + +## Structured result and workflow child contract + +When the ticket declares a result schema, write only the declared bounded `result.json` contract and ensure it validates before completion. A downstream workflow receives copied values through `workflow-inputs.json`; it must not open predecessor run directories or mutable status projections directly. + +For structured executor runs, preserve raw event output. Do not rewrite provider usage, invent cost, or convert missing evidence into zero. Report retries, steering, and errors honestly in the completion handoff. diff --git a/skills/phase-gate-review/SKILL.md b/skills/phase-gate-review/SKILL.md index 9a37395..e29c219 100644 --- a/skills/phase-gate-review/SKILL.md +++ b/skills/phase-gate-review/SKILL.md @@ -21,3 +21,9 @@ Use this skill after all implementation tickets in a phase have completion repor - produce a phase-gate report with an explicit decision. The gate reviewer must not merely summarize implementer reports or accept an unsealed terminal claim as durable evidence. + +## Workflow and provider gates + +For workflow phases, verify the stored snapshot, contiguous event journal, exact node set, approval receipt chains, input-binding digests, child final receipts, retry lineage, and aggregate workflow receipt. Mutating `status.json` must not create approval or change sealed evidence. + +For benchmark phases, inspect bounded raw events and `provider-evidence.json`; confirm delta/cumulative/terminal semantics, cached/reasoning subset handling, cost/currency/catalog rules, and incomplete-trial rejection. Record unavailable paid/external cohorts rather than simulating them. diff --git a/skills/prompt-pack-builder/SKILL.md b/skills/prompt-pack-builder/SKILL.md index d62ca89..d7c2b7a 100644 --- a/skills/prompt-pack-builder/SKILL.md +++ b/skills/prompt-pack-builder/SKILL.md @@ -27,3 +27,7 @@ The generated README and runbook must name `agent-workflow pack validate`, `agen ## Quality rules A ticket must be independently executable but should not duplicate broad context unnecessarily. Use exact paths and current source evidence. Never use one large prompt as a substitute for dependency ordering or review gates. Keep tests narrow and semantic. + +## Workflow-aware packs + +When tickets form a graph, declare cross-phase dependencies and optional structured result contracts explicitly. Prefer one of the authorized workflow templates when its shape fits. Define every downstream input as a named bounded JSON Pointer binding with required/optional behavior; never instruct children to scrape arbitrary predecessor files. Include terminal workflow sealing and independent phase review in acceptance criteria. diff --git a/src/agent_workflow/__init__.py b/src/agent_workflow/__init__.py index b36e74d..a4e2f4f 100644 --- a/src/agent_workflow/__init__.py +++ b/src/agent_workflow/__init__.py @@ -1,2 +1,2 @@ """agent-workflow package.""" -__version__ = "0.1.12" +__version__ = "0.2.1" diff --git a/src/agent_workflow/approval.py b/src/agent_workflow/approval.py new file mode 100644 index 0000000..3c82203 --- /dev/null +++ b/src/agent_workflow/approval.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .errors import WorkflowError +from .lifecycle import lifecycle_receipts +from .receipts import read_sealed_contract, verify_seal_details + + +def lifecycle_disposition(run_dir: Path) -> dict[str, Any] | None: + """Return the latest canonical lifecycle disposition for a sealed child run.""" + run_dir = run_dir.resolve() + final_receipt, expected = verify_seal_details(run_dir) + if final_receipt.get("session_id") != run_dir.name: + raise WorkflowError("approval final receipt belongs to another run") + completion, completion_digest = read_sealed_contract( + run_dir, + final_receipt, + "completion.json", + "agent-workflow/completion/v1", + ) + expected_revision = completion.get("head_revision") + if not isinstance(expected_revision, str) or not expected_revision: + raise WorkflowError("approval requires a completion head revision") + chain = lifecycle_receipts( + run_dir, expected_final_receipt_sha256=expected + ) + if not chain: + return None + entry = chain[-1] + receipt = entry["receipt"] + if receipt.get("action") == "accepted": + if receipt.get("revision") != expected_revision: + raise WorkflowError("approval receipt revision mismatch") + if len(chain) < 2 or chain[-2]["receipt"].get("action") != "reviewed": + raise WorkflowError("accepted approval has no canonical prior review receipt") + if ( + chain[-2]["receipt"].get("score_receipt_sha256") + != receipt.get("score_receipt_sha256") + ): + raise WorkflowError("approval score evidence changed after review") + return { + "action": receipt["action"], + "receipt": receipt, + "receipt_path": str(entry["path"]), + "receipt_name": entry["path"].name, + "receipt_sha256": entry["sha256"], + "final_receipt_sha256": expected, + "final_receipt_path": str(run_dir / "final-receipt.json"), + "completion_sha256": completion_digest, + "revision": expected_revision, + } + + +def accepted_lifecycle_receipt(run_dir: Path) -> dict[str, Any]: + disposition = lifecycle_disposition(run_dir) + if disposition is None: + raise WorkflowError("approval requires a lifecycle receipt") + if disposition["action"] != "accepted": + raise WorkflowError("approval requires an accepted lifecycle receipt") + return dict(disposition["receipt"]) + + +def is_approved(run_dir: Path) -> bool: + try: + disposition = lifecycle_disposition(run_dir) + except WorkflowError: + return False + return disposition is not None and disposition["action"] == "accepted" diff --git a/src/agent_workflow/bindings.py b/src/agent_workflow/bindings.py new file mode 100644 index 0000000..1223014 --- /dev/null +++ b/src/agent_workflow/bindings.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import json +import hashlib +import os +import stat +from pathlib import Path +from typing import Any, Mapping + +from .contracts import read_contract, validate_instance +from .errors import WorkflowError +from .receipts import read_sealed_contract, read_sealed_json, verify_seal_details +from .state import run_dir as session_run_dir +from .workflow import snapshot_sha256 +from .util import atomic_write_json, sha256_file, utc_now + +WORKFLOW_INPUT_BINDINGS_SCHEMA = "agent-workflow/workflow-input-bindings/v1" +MAX_TOTAL_BINDING_BYTES = 4 * 1024 * 1024 +_MISSING = object() + + +def _canonical(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _decode_token(token: str) -> str: + result: list[str] = [] + index = 0 + while index < len(token): + if token[index] != "~": + result.append(token[index]) + index += 1 + continue + if index + 1 >= len(token) or token[index + 1] not in {"0", "1"}: + raise WorkflowError("JSON Pointer contains an invalid escape") + result.append("~" if token[index + 1] == "0" else "/") + index += 2 + return "".join(result) + + +def resolve_json_pointer(document: Any, pointer: str) -> Any: + """Resolve the RFC 6901 data-model subset used by workflow bindings.""" + if pointer == "": + return document + if not pointer.startswith("/"): + raise WorkflowError("JSON Pointer must be empty or start with '/'") + current = document + for raw in pointer[1:].split("/"): + token = _decode_token(raw) + if isinstance(current, Mapping): + if token not in current: + return _MISSING + current = current[token] + elif isinstance(current, list): + if token == "-" or not token.isdigit(): + return _MISSING + if len(token) > 1 and token.startswith("0"): + return _MISSING + index = int(token) + if index >= len(current): + return _MISSING + current = current[index] + else: + return _MISSING + return current + + +def _ancestor_nodes(snapshot: Mapping[str, Any], node_id: str) -> set[str]: + graph = {str(node["node_id"]): list(node["dependencies"]) for node in snapshot["nodes"]} + result: set[str] = set() + pending = list(graph[node_id]) + while pending: + current = pending.pop() + if current in result: + continue + result.add(current) + pending.extend(graph[current]) + return result + + +def _read_sealed_result(run_dir: Path) -> tuple[Any, str, str]: + receipt, expected = verify_seal_details(run_dir) + if receipt.get("session_id") != run_dir.name: + raise WorkflowError(f"source final receipt belongs to another run: {run_dir.name}") + collection, _ = read_sealed_contract( + run_dir, + receipt, + "collections/task-result.json", + "agent-workflow/task-result-collection/v1", + ) + if collection.get("validation_status") != "valid": + raise WorkflowError(f"source run has no valid task result: {run_dir.name}") + document, digest = read_sealed_json(run_dir, receipt, "result.json") + if collection.get("stored_sha256") != digest: + raise WorkflowError(f"source task-result collection digest mismatch: {run_dir.name}") + return document, digest, expected + + +def resolve_node_inputs( + *, + snapshot: Mapping[str, Any], + status: Mapping[str, Any], + node: Mapping[str, Any], + settings: Any, + workflow_run_dir: Path, + attempt: int, +) -> dict[str, Any] | None: + declarations = node.get("input_bindings") + if not isinstance(declarations, Mapping) or not declarations: + return None + node_id = str(node["node_id"]) + ancestors = _ancestor_nodes(snapshot, node_id) + states = {str(item["node_id"]): item for item in status["nodes"]} + records: list[dict[str, Any]] = [] + total = 0 + for name, declaration in sorted(declarations.items()): + source_node_id = str(declaration["source_node_id"]) + if source_node_id not in ancestors: + raise WorkflowError( + f"input binding {name} may reference only a predecessor node" + ) + source = states.get(source_node_id) + if not isinstance(source, Mapping) or source.get("state") != "completed": + raise WorkflowError(f"input binding source is not completed: {source_node_id}") + source_run_id = source.get("run_id") + if not isinstance(source_run_id, str): + raise WorkflowError(f"input binding source has no bound run: {source_node_id}") + source_dir = session_run_dir(settings, source_run_id) + document, result_digest, final_digest = _read_sealed_result(source_dir) + pointer = str(declaration["pointer"]) + value = resolve_json_pointer(document, pointer) + present = value is not _MISSING + if not present and bool(declaration["required"]): + raise WorkflowError( + f"required input binding {name} is missing at pointer {pointer!r}" + ) + selected = None if not present else value + encoded = _canonical(selected) + max_bytes = int(declaration["max_bytes"]) + if len(encoded) > max_bytes: + raise WorkflowError( + f"input binding {name} exceeds its {max_bytes}-byte limit" + ) + total += len(encoded) + if total > MAX_TOTAL_BINDING_BYTES: + raise WorkflowError( + f"workflow input bindings exceed {MAX_TOTAL_BINDING_BYTES} bytes" + ) + records.append( + { + "name": str(name), + "source_node_id": source_node_id, + "source_run_id": source_run_id, + "pointer": pointer, + "required": bool(declaration["required"]), + "present": present, + "value": selected, + "value_size_bytes": len(encoded), + "value_sha256": hashlib.sha256(encoded).hexdigest(), + "source_result_sha256": result_digest, + "source_final_receipt_sha256": final_digest, + } + ) + artifact = { + "schema": WORKFLOW_INPUT_BINDINGS_SCHEMA, + "workflow_id": str(snapshot["workflow_id"]), + "snapshot_sha256": snapshot_sha256(snapshot), + "node_id": node_id, + "attempt": attempt, + "bindings": records, + } + root = workflow_run_dir / "bindings" + root.mkdir(parents=True, exist_ok=True) + path = root / f"{node_id}-attempt-{attempt}.json" + if path.exists(): + try: + info = path.lstat() + except OSError as exc: + raise WorkflowError(f"cannot inspect workflow input binding snapshot {path}: {exc}") from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"workflow input binding snapshot is unsafe: {path}") + if info.st_mode & 0o222: + raise WorkflowError(f"workflow input binding snapshot must be read-only: {path}") + existing = read_contract(path, WORKFLOW_INPUT_BINDINGS_SCHEMA) + expected = dict(artifact) + actual = dict(existing) + actual.pop("created_at", None) + if _canonical(actual) != _canonical(expected): + raise WorkflowError(f"workflow input binding snapshot already exists: {path}") + artifact = existing + else: + artifact["created_at"] = utc_now() + validate_instance( + artifact, + WORKFLOW_INPUT_BINDINGS_SCHEMA, + artifact="workflow input bindings", + ) + atomic_write_json(path, artifact, mode=0o444) + return { + "artifact": artifact, + "path": str(path), + "sha256": sha256_file(path), + } diff --git a/src/agent_workflow/cli.py b/src/agent_workflow/cli.py index a131a85..de84112 100644 --- a/src/agent_workflow/cli.py +++ b/src/agent_workflow/cli.py @@ -42,7 +42,11 @@ from .sessions import wait_for_message from .state import list_statuses, read_status, runs_root from .tmux import attach as attach_tmux -from .util import atomic_write_json, expand_path +from .util import atomic_write_json, expand_path, read_json +from .scheduler import SchedulerService +from .workflow import snapshot_sha256 +from .workflow_service import WorkflowService +from .workflow_templates import AUTHORIZED_TEMPLATES, expand_workflow_template from .worktrees import create as create_worktree from .worktrees import list_worktrees from .worktrees import remove as remove_worktree @@ -82,7 +86,7 @@ def _print_table( def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="agent-workflow") - parser.add_argument("--version", action="version", version="%(prog)s 0.1.12") + parser.add_argument("--version", action="version", version="%(prog)s 0.2.1") parser.add_argument("--config", type=Path, help="override config.toml path") parser.add_argument( "--json", @@ -92,6 +96,29 @@ def build_parser() -> argparse.ArgumentParser: commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("doctor", help="check environment and configuration") + workflow = commands.add_parser("workflow", help="workflow scheduler commands") + workflow_commands = workflow.add_subparsers(dest="workflow_command", required=True) + wf_validate = workflow_commands.add_parser("validate", help="validate a workflow snapshot") + wf_validate.add_argument("snapshot", type=Path) + wf_start = workflow_commands.add_parser("start", help="start a workflow from a snapshot") + wf_start.add_argument("run_dir", type=Path) + wf_start.add_argument("snapshot", type=Path) + wf_status = workflow_commands.add_parser("status", help="show workflow status") + wf_status.add_argument("run_dir", type=Path) + wf_status.add_argument("snapshot", type=Path) + wf_resume = workflow_commands.add_parser("resume", help="resume a workflow after restart") + wf_resume.add_argument("run_dir", type=Path) + wf_resume.add_argument("snapshot", type=Path) + wf_seal = workflow_commands.add_parser("seal", help="seal terminal workflow evidence") + wf_seal.add_argument("run_dir", type=Path) + wf_seal.add_argument("snapshot", type=Path) + wf_verify = workflow_commands.add_parser("verify", help="verify a workflow receipt") + wf_verify.add_argument("run_dir", type=Path) + wf_verify.add_argument("snapshot", type=Path) + wf_template = workflow_commands.add_parser("template", help="expand an authorized workflow template") + wf_template.add_argument("template", choices=AUTHORIZED_TEMPLATES) + wf_template.add_argument("spec", type=Path, help="JSON template request") + wf_template.add_argument("--output", type=Path, required=True) completion = commands.add_parser( "completion", help="generate shell completion from the live parser" @@ -544,6 +571,44 @@ def main(argv: list[str] | None = None) -> int: ], ) return 0 + elif args.command == "workflow": + if args.workflow_command == "template": + spec = read_json(expand_path(args.spec)) + snapshot = expand_workflow_template( + args.template, + workflow_id=str(spec.get("workflow_id", "")), + pack_id=str(spec.get("pack_id", "")), + pack_manifest_sha256=str(spec.get("pack_manifest_sha256", "")), + parameters=spec.get("parameters", {}), + ) + output = expand_path(args.output) + atomic_write_json(output, snapshot) + data = {"output": str(output), "snapshot_sha256": snapshot_sha256(snapshot)} + if args.json: + _print_json(data) + else: + print(output) + return 0 + run_dir = expand_path(getattr(args, "run_dir", Path.cwd())) + service = WorkflowService( + scheduler=SchedulerService( + settings=settings, + run_dir=run_dir, + workdir=run_dir, + ) + ) + if args.workflow_command == "validate": + data = service.validate(args.snapshot) + elif args.workflow_command == "start": + data = service.start(args.snapshot) + elif args.workflow_command == "status": + data = service.status(args.snapshot) + elif args.workflow_command == "resume": + data = service.resume(args.snapshot) + elif args.workflow_command == "seal": + data = service.seal(args.snapshot) + else: + data = service.verify(args.snapshot) elif args.command == "ledger": value = build_ledger( expand_path(args.pack), diff --git a/src/agent_workflow/doctor.py b/src/agent_workflow/doctor.py index 2f8006b..bad522c 100644 --- a/src/agent_workflow/doctor.py +++ b/src/agent_workflow/doctor.py @@ -83,7 +83,7 @@ def run_doctor(settings: Settings) -> dict[str, Any]: } return { "ok": all(checks.values()), - "version": "0.1.12", + "version": "0.2.1", "config_path": str(settings.config_path), "commands": commands, "executors": { diff --git a/src/agent_workflow/eval/compare.py b/src/agent_workflow/eval/compare.py index 5d9fdd3..e85732e 100644 --- a/src/agent_workflow/eval/compare.py +++ b/src/agent_workflow/eval/compare.py @@ -129,12 +129,29 @@ def compare_trials( winner = "baseline" efficiency: dict[str, Any] = {} exclusions: dict[str, int] = {} - for metric in ("duration_seconds", "tokens", "cost"): + for metric in ( + "duration_seconds", + "tokens", + "provider_billed_cost", + "local_estimated_cost", + ): values: list[float] = [] excluded = 0 for key in paired: before = left[key].get(metric) after = right[key].get(metric) + if metric == "provider_billed_cost" and ( + left[key].get("currency") != right[key].get("currency") + ): + excluded += 1 + continue + if metric == "local_estimated_cost" and ( + left[key].get("currency"), left[key].get("price_catalog_id") + ) != ( + right[key].get("currency"), right[key].get("price_catalog_id") + ): + excluded += 1 + continue if not isinstance(before, (int, float)) or not isinstance( after, (int, float) ): diff --git a/src/agent_workflow/eval/scoring.py b/src/agent_workflow/eval/scoring.py index d5f81d4..6de1d6b 100644 --- a/src/agent_workflow/eval/scoring.py +++ b/src/agent_workflow/eval/scoring.py @@ -2,12 +2,14 @@ import hashlib import json +import os +import stat from pathlib import Path from typing import Any, Mapping from ..contracts import read_contract, validate_instance from ..errors import WorkflowError -from ..receipts import verify_seal +from ..receipts import verify_seal_details from ..util import atomic_write_json, sha256_file from .junit import compare_junit from .oracles import scan_for_leak @@ -27,10 +29,22 @@ } -def _load(path: Path) -> dict[str, Any]: +def _load(path: Path, *, require_read_only: bool = False) -> dict[str, Any]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot read evaluation receipt {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"evaluation receipt must be a regular file: {path}") + if require_read_only and info.st_mode & 0o222: + raise WorkflowError(f"content-addressed score receipt must be read-only: {path}") + data = stream.read() + try: + value = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise WorkflowError(f"cannot read evaluation receipt {path}: {exc}") from exc if not isinstance(value, dict): raise WorkflowError(f"evaluation receipt must be an object: {path}") @@ -74,8 +88,11 @@ def _write_content_addressed(output_dir: Path, value: dict[str, Any]) -> Path: digest = hashlib.sha256(encoded).hexdigest() output_dir.mkdir(parents=True, exist_ok=True) path = output_dir / f"{value['scorer']['id']}-{digest}.json" - if not path.exists(): - atomic_write_json(path, value) + if path.exists() or path.is_symlink(): + if _load(path, require_read_only=True) != value: + raise WorkflowError(f"content-addressed score receipt changed: {path}") + else: + atomic_write_json(path, value, mode=0o444) return path @@ -139,7 +156,13 @@ def validate_score_set( encoded = json.dumps(score, sort_keys=True, separators=(",", ":")).encode() digest = hashlib.sha256(encoded).hexdigest() score_path = run_dir / "scores" / f"{identifier}-{digest}.json" - if not score_path.is_file() or _load(score_path) != score: + try: + stored_score = _load(score_path, require_read_only=True) + except WorkflowError as exc: + raise WorkflowError( + f"content-addressed score receipt is missing or changed: {score_path}: {exc}" + ) from exc + if stored_score != score: raise WorkflowError( f"content-addressed score receipt is missing or changed: {score_path}" ) @@ -172,8 +195,9 @@ def score_trial( oracle_canary: bytes | None = None, ) -> dict[str, Any]: run_dir = run_dir.resolve() - final = verify_seal(run_dir, expected_sha256=expected_final_receipt_sha256) - final_hash = sha256_file(run_dir / "final-receipt.json") + final, final_hash = verify_seal_details( + run_dir, expected_sha256=expected_final_receipt_sha256 + ) scores: list[dict[str, Any]] = [] runtime_path = run_dir / "evaluation-runtime.json" runtime = _load(runtime_path) if runtime_path.is_file() else None diff --git a/src/agent_workflow/eval/trials.py b/src/agent_workflow/eval/trials.py index 1d644e7..96e7018 100644 --- a/src/agent_workflow/eval/trials.py +++ b/src/agent_workflow/eval/trials.py @@ -6,11 +6,13 @@ from pathlib import Path from typing import Any, Iterable +from ..contracts import read_contract, validate_instance from ..errors import WorkflowError -from ..receipts import verify_seal +from ..receipts import read_sealed_contract, verify_seal_details from ..util import atomic_write_json, sha256_file +from .scoring import validate_score_set -TRIAL_EVIDENCE_SCHEMA = "agent-workflow/trial-evidence/v1" +TRIAL_EVIDENCE_SCHEMA = "agent-workflow/trial-evidence/v2" def _load(path: Path) -> dict[str, Any]: @@ -24,7 +26,13 @@ def _load(path: Path) -> dict[str, Any]: def _number(value: object) -> int | float | None: - return value if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0 else None + return ( + value + if isinstance(value, (int, float)) + and not isinstance(value, bool) + and value >= 0 + else None + ) def _stage(metrics: dict[str, Any], name: str) -> dict[str, Any]: @@ -38,35 +46,102 @@ def _stage(metrics: dict[str, Any], name: str) -> dict[str, Any]: def extract_trial(run_dir: Path) -> dict[str, Any]: - """Extract one comparison-ready trial from a sealed run without mutation.""" + """Extract one comparison-ready trial from a sealed, complete evidence run.""" run_dir = run_dir.resolve() receipt_path = run_dir / "final-receipt.json" if not receipt_path.is_file(): raise WorkflowError(f"final receipt is missing: {receipt_path}") - receipt = verify_seal(run_dir, expected_sha256=sha256_file(receipt_path)) - provenance = _load(run_dir / "run-provenance.json") - metrics = _load(run_dir / "execution-metrics.json") - score = _load(run_dir / "scores" / "score-set.json") + receipt, final_receipt_sha256 = verify_seal_details(run_dir) + provenance, _ = read_sealed_contract( + run_dir, + receipt, + "run-provenance.json", + "agent-workflow/run-provenance/v1", + ) + metrics, _ = read_sealed_contract( + run_dir, + receipt, + "execution-metrics.json", + "agent-workflow/execution-metrics/v1", + ) + provider, _ = read_sealed_contract( + run_dir, + receipt, + "provider-evidence.json", + "agent-workflow/provider-evidence/v1", + ) + if not provider["capture_complete"]: + raise WorkflowError("provider evidence capture is incomplete") + if provider["malformed_event_count"]: + raise WorkflowError("provider evidence contains malformed raw events") + if not provider["usage_complete"]: + raise WorkflowError( + "provider usage evidence is incomplete: " + + ", ".join(provider["incomplete_reasons"]) + ) + score = validate_score_set( + run_dir, + _load(run_dir / "scores" / "score-set.json"), + final_receipt=receipt, + expected_final_receipt_sha256=final_receipt_sha256, + ) verdict = score.get("verdict") if verdict not in {"pass", "fail", "invalid"}: raise WorkflowError("score-set has no valid verdict") total = _stage(metrics, "total") - runtime_path = run_dir / "evaluation-runtime.json" - runtime = _load(runtime_path) if runtime_path.is_file() else {} - input_tokens, output_tokens = _number(total.get("input_tokens")), _number(total.get("output_tokens")) - tokens = input_tokens + output_tokens if input_tokens is not None and output_tokens is not None else None + aggregate = provider["aggregate"] + sealed_paths = { + item.get("path") + for item in receipt.get("artifacts", []) + if isinstance(item, dict) + } + runtime = ( + read_sealed_contract( + run_dir, + receipt, + "evaluation-runtime.json", + "agent-workflow/evaluation-runtime/v1", + )[0] + if "evaluation-runtime.json" in sealed_paths + else {} + ) + input_tokens = _number(aggregate.get("input_tokens")) + output_tokens = _number(aggregate.get("output_tokens")) + tokens = ( + input_tokens + output_tokens + if input_tokens is not None and output_tokens is not None + else None + ) + provider_cost = _number(aggregate.get("provider_billed_cost")) + local_cost = _number(aggregate.get("local_estimated_cost")) + currency = aggregate.get("currency") if isinstance(aggregate.get("currency"), str) else None + catalog = ( + aggregate.get("price_catalog_id") + if isinstance(aggregate.get("price_catalog_id"), str) + else None + ) + if provider_cost is not None and currency is None: + raise WorkflowError("provider-billed cost requires a currency") + if local_cost is not None and (currency is None or catalog is None): + raise WorkflowError("local cost estimate requires currency and price_catalog_id") artifacts = { item["path"]: item["sha256"] for item in receipt.get("artifacts", []) - if isinstance(item, dict) and isinstance(item.get("path"), str) and isinstance(item.get("sha256"), str) + if isinstance(item, dict) + and isinstance(item.get("path"), str) + and isinstance(item.get("sha256"), str) } + def field(name: str) -> Any: return runtime.get(name, provenance.get(name)) - return { + + result = { "schema": TRIAL_EVIDENCE_SCHEMA, "trial_id": str(provenance.get("session_id") or run_dir.name), "run_path": str(run_dir), - "final_receipt_sha256": sha256_file(receipt_path), + "final_receipt_sha256": final_receipt_sha256, + "provider_evidence_sha256": artifacts.get("provider-evidence.json"), + "raw_events_sha256": provider["raw_events_sha256"], "verdict": verdict, "fixture_revision": field("fixture_revision"), "task_id": field("ticket_id") or field("task_id"), @@ -81,18 +156,25 @@ def field(name: str) -> Any: "repetition": field("repetition"), "duration_seconds": _number(total.get("elapsed_seconds")), "input_tokens": input_tokens, - "cached_input_tokens": _number(total.get("cached_input_tokens")), + "cached_input_tokens": _number(aggregate.get("cached_input_tokens")), + "cache_write_input_tokens": _number(aggregate.get("cache_write_input_tokens")), "output_tokens": output_tokens, - "provider_total_tokens": _number(total.get("provider_total_tokens")), + "reasoning_output_tokens": _number(aggregate.get("reasoning_output_tokens")), + "provider_total_tokens": _number(aggregate.get("provider_total_tokens")), "tokens": tokens, - "cost": _number(total.get("cost")), - "currency": total.get("currency") if isinstance(total.get("currency"), str) else None, + "provider_billed_cost": provider_cost, + "local_estimated_cost": local_cost, + "currency": currency, + "price_catalog_id": catalog, + "retry_of_run_id": provider.get("retry_of_run_id"), "retry_count": total.get("retry_count") if isinstance(total.get("retry_count"), int) else None, "errors": total.get("errors") if isinstance(total.get("errors"), list) else [], "steer_count": total.get("steer_count") if isinstance(total.get("steer_count"), int) else None, "steer_acknowledged_count": total.get("steer_acknowledged_count") if isinstance(total.get("steer_acknowledged_count"), int) else None, "source_artifacts": artifacts, } + validate_instance(result, TRIAL_EVIDENCE_SCHEMA, artifact="trial evidence") + return result def collect_trials(run_dirs: Iterable[Path], output: Path) -> dict[str, Any]: @@ -105,18 +187,28 @@ def collect_trials(run_dirs: Iterable[Path], output: Path) -> dict[str, Any]: "collected_at": datetime.now(timezone.utc).isoformat(), "trials": trials, } + validate_instance(value, TRIAL_EVIDENCE_SCHEMA, artifact="trial evidence collection") atomic_write_json(output, value) return value def load_trials(path: Path) -> list[dict[str, Any]]: - value = _load(path) - if value.get("schema") != TRIAL_EVIDENCE_SCHEMA or not isinstance(value.get("trials"), list): - raise WorkflowError(f"invalid trial evidence file: {path}") - trials = value["trials"] - if not all(isinstance(item, dict) and item.get("schema") == TRIAL_EVIDENCE_SCHEMA for item in trials): - raise WorkflowError(f"invalid trial record in {path}") - currencies = {item.get("currency") for item in trials if item.get("cost") is not None} - if len(currencies) > 1: - raise WorkflowError(f"multiple cost currencies in evidence file: {path}") + value = read_contract(path, TRIAL_EVIDENCE_SCHEMA) + trials = value.get("trials") + if not isinstance(trials, list): + raise WorkflowError(f"trial evidence collection expected: {path}") + provider_currencies = { + item.get("currency") + for item in trials + if item.get("provider_billed_cost") is not None + } + if len(provider_currencies) > 1: + raise WorkflowError(f"multiple provider cost currencies in evidence file: {path}") + local_keys = { + (item.get("currency"), item.get("price_catalog_id")) + for item in trials + if item.get("local_estimated_cost") is not None + } + if len(local_keys) > 1: + raise WorkflowError(f"multiple local price catalogs/currencies in evidence file: {path}") return trials diff --git a/src/agent_workflow/ledger.py b/src/agent_workflow/ledger.py index 5d4fa44..af0c868 100644 --- a/src/agent_workflow/ledger.py +++ b/src/agent_workflow/ledger.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any +from .approval import is_approved from .manifests import yaml from .miniyaml import load_task_manifest @@ -112,7 +113,10 @@ def build_ledger(pack_root: Path, runs_root: Path) -> dict[str, Any]: blocked = [ dependency for dependency in row["dependencies"] - if by_ticket.get(dependency, {}).get("disposition") != "accepted" + if ( + (dependency_row := by_ticket.get(dependency)) is None + or not is_approved(runs_root / dependency_row["session"]) + ) ] if blocked and row["status"] == "missing" and not row.get("error"): row["next_action"] = "wait for dependencies: " + ", ".join(blocked) diff --git a/src/agent_workflow/lifecycle.py b/src/agent_workflow/lifecycle.py index 55e3a1b..4a673c9 100644 --- a/src/agent_workflow/lifecycle.py +++ b/src/agent_workflow/lifecycle.py @@ -1,36 +1,146 @@ from __future__ import annotations +import hashlib import json import os +import re +import stat from pathlib import Path from typing import Any, Literal from .config import Settings -from .contracts import read_contract, validate_instance +from .contracts import validate_instance from .errors import WorkflowError from .eval.scoring import validate_score_set -from .receipts import verify_seal -from .state import read_status, run_dir, update_status -from .util import sha256_file, utc_now +from .receipts import read_sealed_contract, verify_seal_details +from .state import run_dir, update_status +from .util import fsync_directory, utc_now Action = Literal["reviewed", "accepted", "rejected"] +_RECEIPT_NAME = re.compile(r"^(?P[0-9]{6})-(?Previewed|accepted|rejected)\.json$") -def _score( - run: Path, final_hash: str, final_receipt: dict[str, Any] -) -> tuple[dict[str, Any], str]: - path = run / "scores" / "score-set.json" +def _read_lifecycle_receipt(path: Path) -> tuple[dict[str, Any], str]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open lifecycle receipt {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError( + f"lifecycle receipt must be a regular non-symlink file: {path}" + ) + if info.st_mode & 0o222: + raise WorkflowError(f"lifecycle receipt must be read-only: {path}") + data = stream.read() + try: + receipt = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkflowError(f"cannot read lifecycle receipt {path}: {exc}") from exc + if not isinstance(receipt, dict): + raise WorkflowError(f"lifecycle receipt must be a JSON object: {path}") + validate_instance( + receipt, "agent-workflow/lifecycle-receipt/v1", artifact=str(path) + ) + return receipt, hashlib.sha256(data).hexdigest() + + +def lifecycle_receipts(run: Path, *, expected_final_receipt_sha256: str | None = None) -> list[dict[str, Any]]: + """Reconstruct the immutable lifecycle receipt chain from its canonical directory. + + Mutable status fields are deliberately ignored. Every receipt must be a + contiguous, read-only, regular file directly below ``receipts/`` and must + identify the run whose directory contains it. + """ + root = run / "receipts" + if not root.exists() and not root.is_symlink(): + return [] + try: + root_info = root.lstat() + except OSError as exc: + raise WorkflowError(f"cannot inspect lifecycle receipt root {root}: {exc}") from exc + if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode): + raise WorkflowError(f"lifecycle receipt root is unsafe: {root}") + try: + root.resolve().relative_to(run.resolve()) + except ValueError as exc: + raise WorkflowError(f"lifecycle receipt root escapes run directory: {root}") from exc + entries: list[dict[str, Any]] = [] + paths = sorted(root.iterdir(), key=lambda item: item.name) + expected_sequence = 1 + for path in paths: + match = _RECEIPT_NAME.fullmatch(path.name) + if match is None: + raise WorkflowError(f"unexpected lifecycle receipt artifact: {path.name}") + sequence = int(match.group("sequence")) + if sequence != expected_sequence: + raise WorkflowError( + f"lifecycle receipt sequence mismatch: expected {expected_sequence:06d}" + ) + receipt, receipt_sha256 = _read_lifecycle_receipt(path) + if receipt.get("action") != match.group("action"): + raise WorkflowError(f"lifecycle receipt action does not match filename: {path}") + if receipt.get("session_id") != run.name: + raise WorkflowError(f"lifecycle receipt belongs to another run: {path}") + if ( + expected_final_receipt_sha256 is not None + and receipt.get("final_receipt_sha256") != expected_final_receipt_sha256 + ): + raise WorkflowError(f"lifecycle receipt final-receipt digest mismatch: {path}") + entries.append( + { + "sequence": sequence, + "path": path, + "sha256": receipt_sha256, + "receipt": receipt, + } + ) + expected_sequence += 1 + return entries + + +def _read_score_set(path: Path) -> tuple[dict[str, Any], str]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + descriptor = os.open(path, flags) + except OSError as exc: raise WorkflowError(f"valid score set required: {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"valid score set required: {path}: not a regular file") + data = stream.read() + try: + value = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkflowError(f"valid score set required: {path}: {exc}") from exc + if not isinstance(value, dict): + raise WorkflowError(f"valid score set required: {path}: expected JSON object") + return value, hashlib.sha256(data).hexdigest() + + +def _score( + run: Path, + final_hash: str, + final_receipt: dict[str, Any], + *, + required: bool, +) -> tuple[dict[str, Any] | None, str | None]: + path = run / "scores" / "score-set.json" + if not path.exists() and not path.is_symlink(): + if required: + raise WorkflowError(f"valid score set required: {path}: file not found") + return None, None + value, score_digest = _read_score_set(path) value = validate_score_set( run, value, final_receipt=final_receipt, expected_final_receipt_sha256=final_hash, ) - return value, sha256_file(path) + return value, score_digest def _append_receipt(run: Path, value: dict[str, Any]) -> Path: @@ -38,18 +148,28 @@ def _append_receipt(run: Path, value: dict[str, Any]) -> Path: value, "agent-workflow/lifecycle-receipt/v1", artifact="lifecycle receipt" ) root = run / "receipts" - root.mkdir(parents=True, exist_ok=True) - sequence = len(list(root.glob("[0-9]*-*.json"))) + 1 + if root.exists() or root.is_symlink(): + try: + root_info = root.lstat() + except OSError as exc: + raise WorkflowError(f"cannot inspect lifecycle receipt root {root}: {exc}") from exc + if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode): + raise WorkflowError(f"lifecycle receipt root is unsafe: {root}") + else: + root.mkdir(parents=True, mode=0o700) + fsync_directory(run) + sequence = len(lifecycle_receipts(run)) + 1 path = root / f"{sequence:06d}-{value['action']}.json" encoded = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode() try: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o444) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o444) except FileExistsError as exc: raise WorkflowError(f"lifecycle receipt already exists: {path}") from exc with os.fdopen(descriptor, "wb") as stream: stream.write(encoded) stream.flush() os.fsync(stream.fileno()) + fsync_directory(root) return path @@ -64,37 +184,54 @@ def record( ) -> dict[str, Any]: if not actor.strip() or not reason.strip(): raise WorkflowError("lifecycle actor and reason must be non-empty") - status = read_status(settings, session_id) - if status.get("status") != "completed": - raise WorkflowError("only a completed execution can be reviewed") run = run_dir(settings, session_id) - expected = status.get("final_receipt_sha256") - if not isinstance(expected, str): - raise WorkflowError("status has no recorded final receipt checksum") - final_receipt = verify_seal(run, expected_sha256=expected) - score, score_hash = _score(run, expected, final_receipt) - completion = read_contract( - run / "completion.json", "agent-workflow/completion/v1" + final_receipt, expected = verify_seal_details(run) + final_status, _ = read_sealed_contract( + run, + final_receipt, + "final-status.json", + "agent-workflow/session-status/v2", ) - collection = read_contract( - run / "collections" / "completion.json", + if final_status.get("session_id") != session_id: + raise WorkflowError("final status belongs to another run") + if final_status.get("status") != "completed": + raise WorkflowError("only a completed execution can be reviewed") + sealed_paths = { + item.get("path") + for item in final_receipt.get("artifacts", []) + if isinstance(item, dict) + } + evaluation_required = bool(final_status.get("evaluation_path")) or ( + "evaluation-runtime.json" in sealed_paths + ) + score, score_hash = _score( + run, expected, final_receipt, required=evaluation_required + ) + completion, _ = read_sealed_contract( + run, + final_receipt, + "completion.json", + "agent-workflow/completion/v1", + ) + collection, _ = read_sealed_contract( + run, + final_receipt, + "collections/completion.json", "agent-workflow/completion-collection/v1", ) - independent = actor != status.get("executor") + chain = lifecycle_receipts(run, expected_final_receipt_sha256=expected) + if chain and chain[-1]["receipt"].get("action") == "accepted": + raise WorkflowError("lifecycle disposition is already terminal") + independent = actor != final_status.get("executor") if action == "accepted": - if status.get("disposition") != "reviewed": + if not chain or chain[-1]["receipt"].get("action") != "reviewed": raise WorkflowError("acceptance requires a prior reviewed disposition") - if score.get("verdict") != "pass": + reviewed = chain[-1]["receipt"] + if score is not None and score.get("verdict") != "pass": raise WorkflowError("acceptance requires a passing deterministic score set") - reviewed_path = status.get("lifecycle_receipt_path") - if not isinstance(reviewed_path, str): - raise WorkflowError("acceptance requires the prior review receipt") - reviewed = read_contract( - Path(reviewed_path), "agent-workflow/lifecycle-receipt/v1" - ) if reviewed.get("score_receipt_sha256") != score_hash: raise WorkflowError("score set changed after review") - if status.get("tier") in {"high", "critical"} and not reviewed.get( + if final_status.get("tier") in {"high", "critical"} and not reviewed.get( "reviewer_independent" ): raise WorkflowError( @@ -104,7 +241,7 @@ def record( raise WorkflowError("acceptance requires completion result 'completed'") if collection.get("validation_status") != "valid": raise WorkflowError("acceptance requires a valid collected completion") - if status.get("tier") not in {"low", "medium", "high", "critical"}: + if final_status.get("tier") not in {"low", "medium", "high", "critical"}: raise WorkflowError( "acceptance requires a recorded task tier; relaunch with --tier" ) @@ -113,8 +250,10 @@ def record( raise WorkflowError( f"accepted revision mismatch: {revision}; expected {expected_revision}" ) - if status.get("tier") in {"high", "critical"} and not independent: + if final_status.get("tier") in {"high", "critical"} and not independent: raise WorkflowError("high-risk acceptance requires an independent reviewer") + elif action == "reviewed" and chain and chain[-1]["receipt"].get("action") == "reviewed": + raise WorkflowError("run is already reviewed") value = { "schema": "agent-workflow/lifecycle-receipt/v1", "session_id": session_id, @@ -128,14 +267,18 @@ def record( "reviewer_independent": independent, } path = _append_receipt(run, value) + projection_updates: dict[str, Any] = { + "disposition": action, + "disposition_at": value["created_at"], + "disposition_actor": actor, + "lifecycle_receipt_path": str(path), + } + if action == "accepted": + projection_updates["accepted_revision"] = revision result = update_status( settings, session_id, - disposition=action, - disposition_at=value["created_at"], - disposition_actor=actor, - lifecycle_receipt_path=str(path), - accepted_revision=revision if action == "accepted" else status.get("accepted_revision"), + **projection_updates, _actor=actor, _reason=reason, _receipt_refs=(str(path),), diff --git a/src/agent_workflow/mcp/SDK_SNAPSHOT.md b/src/agent_workflow/mcp/SDK_DEPENDENCY.md similarity index 67% rename from src/agent_workflow/mcp/SDK_SNAPSHOT.md rename to src/agent_workflow/mcp/SDK_DEPENDENCY.md index 700a40d..e352d0f 100644 --- a/src/agent_workflow/mcp/SDK_SNAPSHOT.md +++ b/src/agent_workflow/mcp/SDK_DEPENDENCY.md @@ -1,4 +1,4 @@ -# MCP Python SDK snapshot +# MCP Python SDK dependency Research fetch: 2026-07-24. @@ -11,6 +11,6 @@ Research fetch: 2026-07-24. says v1.x remains the production line. - Approved package constraint: `mcp==1.28.1`. -This directory is a review snapshot, not yet an approved runtime vendoring -boundary. The implementation must use the package's public server/client APIs, -keep the SDK optional, and avoid copying SDK internals into `agent_workflow`. +The SDK is not vendored. The implementation uses the optional pinned package +through public server/client APIs. This file retains the dependency decision and +research provenance without carrying an unused third-party source tree. diff --git a/src/agent_workflow/mcp/sdk/.git-blame-ignore-revs b/src/agent_workflow/mcp/sdk/.git-blame-ignore-revs deleted file mode 100644 index 9fd6c03..0000000 --- a/src/agent_workflow/mcp/sdk/.git-blame-ignore-revs +++ /dev/null @@ -1,5 +0,0 @@ -# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856 -543961968c0634e93d919d509cce23a1d6a56c21 - -# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553 -89e9c43acf7e23cf766357d776ec1ce63ac2c58e diff --git a/src/agent_workflow/mcp/sdk/.gitattribute b/src/agent_workflow/mcp/sdk/.gitattribute deleted file mode 100644 index 0ab3744..0000000 --- a/src/agent_workflow/mcp/sdk/.gitattribute +++ /dev/null @@ -1,2 +0,0 @@ -# Generated -uv.lock linguist-generated=true diff --git a/src/agent_workflow/mcp/sdk/.gitignore b/src/agent_workflow/mcp/sdk/.gitignore deleted file mode 100644 index 348785e..0000000 --- a/src/agent_workflow/mcp/sdk/.gitignore +++ /dev/null @@ -1,175 +0,0 @@ -.DS_Store -scratch/ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -.ruff_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site -/.worktrees/ - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ - -# vscode -.vscode/ -.windsurfrules -**/CLAUDE.local.md - -# claude code -.claude/ diff --git a/src/agent_workflow/mcp/sdk/.pre-commit-config.yaml b/src/agent_workflow/mcp/sdk/.pre-commit-config.yaml deleted file mode 100644 index e0d56a2..0000000 --- a/src/agent_workflow/mcp/sdk/.pre-commit-config.yaml +++ /dev/null @@ -1,63 +0,0 @@ -fail_fast: true - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: end-of-file-fixer - - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 - hooks: - - id: prettier - types_or: [yaml, json5] - - - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.45.0 - hooks: - - id: markdownlint - args: - [ - "--fix", - "--config", - "pyproject.toml", - "--configPointer", - "/tool/markdown/lint", - ] - types: [markdown] - - - repo: local - hooks: - - id: ruff-format - name: Ruff Format - entry: uv run --frozen ruff - args: [format] - language: system - types: [python] - pass_filenames: false - - id: ruff - name: Ruff - entry: uv run --frozen ruff - args: ["check", "--fix", "--exit-non-zero-on-fix"] - types: [python] - language: system - pass_filenames: false - exclude: ^README\.md$ - - id: pyright - name: pyright - entry: uv run --frozen pyright - language: system - types: [python] - pass_filenames: false - - id: uv-lock-check - name: Check uv.lock is up to date - entry: uv lock --check - language: system - files: ^(pyproject\.toml|uv\.lock)$ - pass_filenames: false - - id: doc-snippets - name: Check doc snippets are up to date - entry: uv run --frozen python scripts/update_doc_snippets.py --check - language: system - files: ^(README\.md|docs/.*\.md|examples/.*\.py|scripts/update_doc_snippets\.py)$ - pass_filenames: false diff --git a/src/agent_workflow/mcp/sdk/CLAUDE.md b/src/agent_workflow/mcp/sdk/CLAUDE.md deleted file mode 100644 index 986e64d..0000000 --- a/src/agent_workflow/mcp/sdk/CLAUDE.md +++ /dev/null @@ -1,137 +0,0 @@ -# Development Guidelines - -This document contains critical information about working with this codebase. Follow these guidelines precisely. - -## Core Development Rules - -1. Package Management - - ONLY use uv, NEVER pip - - Installation: `uv add package` - - Running tools: `uv run tool` - - Upgrading: `uv add --dev package --upgrade-package package` - - FORBIDDEN: `uv pip install`, `@latest` syntax - -2. Code Quality - - Type hints required for all code - - Public APIs must have docstrings - - Functions must be focused and small - - Follow existing patterns exactly - - Line length: 120 chars maximum - -3. Testing Requirements - - Framework: `uv run --frozen pytest` - - Async testing: use anyio, not asyncio - - Coverage: test edge cases and errors - - New features require tests - - Bug fixes require regression tests - - Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: - - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test - - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` - - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) - - Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs - -- For commits fixing bugs or adding features based on user reports add: - - ```bash - git commit --trailer "Reported-by:" - ``` - - Where `` is the name of the user. - -- For commits related to a Github issue, add - - ```bash - git commit --trailer "Github-Issue:#" - ``` - -- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never - mention the tool used to create the commit message or PR. - -## Pull Requests - -- Create a detailed message of what changed. Focus on the high level description of - the problem it tries to solve, and how it is solved. Don't go into the specifics of the - code unless it adds clarity. - -- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never - mention the tool used to create the commit message or PR. - -## Python Tools - -## Code Formatting - -1. Ruff - - Format: `uv run --frozen ruff format .` - - Check: `uv run --frozen ruff check .` - - Fix: `uv run --frozen ruff check . --fix` - - Critical issues: - - Line length (88 chars) - - Import sorting (I001) - - Unused imports - - Line wrapping: - - Strings: use parentheses - - Function calls: multi-line with proper indent - - Imports: split into multiple lines - -2. Type Checking - - Tool: `uv run --frozen pyright` - - Requirements: - - Explicit None checks for Optional - - Type narrowing for strings - - Version warnings can be ignored if checks pass - -3. Pre-commit - - Config: `.pre-commit-config.yaml` - - Runs: on git commit - - Tools: Prettier (YAML/JSON), Ruff (Python) - - Ruff updates: - - Check PyPI versions - - Update config rev - - Commit config first - -## Error Resolution - -1. CI Failures - - Fix order: - 1. Formatting - 2. Type errors - 3. Linting - - Type errors: - - Get full line context - - Check Optional types - - Add type narrowing - - Verify function signatures - -2. Common Issues - - Line length: - - Break strings with parentheses - - Multi-line function calls - - Split imports - - Types: - - Add None checks - - Narrow string types - - Match existing patterns - - Pytest: - - If the tests aren't finding the anyio pytest mark, try adding PYTEST_DISABLE_PLUGIN_AUTOLOAD="" - to the start of the pytest run command eg: - `PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest` - -3. Best Practices - - Check git status before commits - - Run formatters before type checks - - Keep changes minimal - - Follow existing patterns - - Document public APIs - - Test thoroughly - -## Exception Handling - -- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** - - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` -- **Catch specific exceptions** where possible: - - File ops: `except (OSError, PermissionError):` - - JSON: `except json.JSONDecodeError:` - - Network: `except (ConnectionError, TimeoutError):` -- **Only catch `Exception` for**: - - Top-level handlers that must not crash - - Cleanup blocks (log at debug level) diff --git a/src/agent_workflow/mcp/sdk/CODE_OF_CONDUCT.md b/src/agent_workflow/mcp/sdk/CODE_OF_CONDUCT.md deleted file mode 100644 index 985a285..0000000 --- a/src/agent_workflow/mcp/sdk/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -. Translations are available at -. diff --git a/src/agent_workflow/mcp/sdk/CONTRIBUTING.md b/src/agent_workflow/mcp/sdk/CONTRIBUTING.md deleted file mode 100644 index 2312c94..0000000 --- a/src/agent_workflow/mcp/sdk/CONTRIBUTING.md +++ /dev/null @@ -1,110 +0,0 @@ -# Contributing - -Thank you for your interest in contributing to the MCP Python SDK! This document provides guidelines and instructions for contributing. - -## Development Setup - -1. Make sure you have Python 3.10+ installed -2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) -3. Fork the repository -4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` -5. Install dependencies: - -```bash -uv sync --frozen --all-extras --dev -``` - -6. Set up pre-commit hooks: - -```bash -uv tool install pre-commit --with pre-commit-uv --force-reinstall -``` - -## Development Workflow - -1. Choose the correct branch for your changes: - - For bug fixes to a released version: use the latest release branch (e.g. v1.1.x for 1.1.3) - - For new features: use the main branch (which will become the next minor/major version) - - If unsure, ask in an issue first - -2. Create a new branch from your chosen base branch - -3. Make your changes - -4. Ensure tests pass: - -```bash -uv run pytest -``` - -5. Run type checking: - -```bash -uv run pyright -``` - -6. Run linting: - -```bash -uv run ruff check . -uv run ruff format . -``` - -7. Update README snippets if you modified example code: - -```bash -uv run scripts/update_readme_snippets.py -``` - -8. (Optional) Run pre-commit hooks on all files: - -```bash -pre-commit run --all-files -``` - -9. Submit a pull request to the same branch you branched from - -## Dependency Update Policy - -See [DEPENDENCY_POLICY.md](DEPENDENCY_POLICY.md) for the full dependency update policy. - -When bumping a dependency version manually, update the constraint in `pyproject.toml` then run `uv lock --resolution lowest-direct` (see [RELEASE.md](RELEASE.md)). - -Security-relevant dependency updates (P0) are applied within 7 days of public disclosure and backported to active release branches. - -The SDK currently supports Python 3.10 through 3.14. New CPython releases are supported within one minor SDK release of their stable release date. - -## Triage Process - -New issues are triaged by a maintainer within 2 business days. Triage means adding an appropriate label and determining whether the issue is valid. - -Issues are labeled per the [SDK Tiering System](https://modelcontextprotocol.io/community/sdk-tiers): - -- **Type** (pick one): `bug`, `enhancement`, `question` -- **Status** (pick one): `needs confirmation`, `needs repro`, `ready for work`, `good first issue`, `help wanted` -- **Priority** (if actionable): `P0`, `P1`, `P2`, `P3` - -P0 issues are security vulnerabilities (CVSS ≥ 7.0) or core functionality failures that prevent basic MCP operations (connection establishment, message exchange, or use of core primitives). P0 issues must be resolved within 7 days. - -## Code Style - -- We use `ruff` for linting and formatting -- Follow PEP 8 style guidelines -- Add type hints to all functions -- Include docstrings for public APIs - -## Pull Request Process - -1. Update documentation as needed -2. Add tests for new functionality -3. Ensure CI passes -4. Maintainers will review your code -5. Address review feedback - -## Code of Conduct - -Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. - -## License - -By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/src/agent_workflow/mcp/sdk/DEPENDENCY_POLICY.md b/src/agent_workflow/mcp/sdk/DEPENDENCY_POLICY.md deleted file mode 100644 index 28f9aab..0000000 --- a/src/agent_workflow/mcp/sdk/DEPENDENCY_POLICY.md +++ /dev/null @@ -1,30 +0,0 @@ -# Dependency Policy - -As a library consumed by downstream projects, the MCP Python SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. - -## Update Triggers - -Dependencies are updated when: - -- A **security vulnerability** is disclosed (via GitHub security alerts or PyPI advisories) in a dependency that directly affects the SDK's functionality or its consumers. -- A bug in a dependency directly affects the SDK. -- A new dependency feature is needed for SDK development. -- A dependency drops support for a Python version the SDK still targets. - -Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. - -## What We Don't Do - -The SDK does not run ad-hoc version bumps for PyPI dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. - -Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. - -## Automated Tooling - -- **Lockfile refresh**: The upstream lockfile is updated automatically every Thursday at 08:00 UTC by the [`weekly-lockfile-update.yml`](https://github.com/modelcontextprotocol/python-sdk/blob/v1.28.1/.github/workflows/weekly-lockfile-update.yml) workflow, which runs `uv lock --upgrade` and opens a PR. This does not alter the minimum or maximum versions for dependencies of the `mcp` package itself. -- **GitHub security updates** are enabled at the repository level and automatically open pull requests for packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. -- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see the [upstream Dependabot configuration](https://github.com/modelcontextprotocol/python-sdk/blob/v1.28.1/.github/dependabot.yml)). - -## Pinning and Ranges - -Production dependencies use compatible-release specifiers (`~=`) or lower-bound constraints (`>=`) to allow compatible updates. Exact versions are pinned only when necessary to work around a specific issue. The lockfile (`uv.lock`) records exact resolved versions for reproducible installs. diff --git a/src/agent_workflow/mcp/sdk/LICENSE b/src/agent_workflow/mcp/sdk/LICENSE deleted file mode 100644 index 3d48435..0000000 --- a/src/agent_workflow/mcp/sdk/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Anthropic, PBC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/agent_workflow/mcp/sdk/README.md b/src/agent_workflow/mcp/sdk/README.md deleted file mode 100644 index 9eaef01..0000000 --- a/src/agent_workflow/mcp/sdk/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# MCP Python SDK - -
- -Python implementation of the Model Context Protocol (MCP) - -[![PyPI][pypi-badge]][pypi-url] -[![MIT licensed][mit-badge]][mit-url] -[![Python Version][python-badge]][python-url] -[![Documentation][docs-badge]][docs-url] -[![Protocol][protocol-badge]][protocol-url] -[![Specification][spec-badge]][spec-url] - -
- -> **This documents v1.x, the stable release line of the MCP Python SDK. v2 is in alpha.** -> -> v2 pre-releases are published to PyPI as `2.0.0aN`. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a1`), so v1.x users are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable v2 release lands.** See the [v2 documentation](https://github.com/modelcontextprotocol/python-sdk/blob/main/README.v2.md) and the [migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md) for what's changing. -> -> v1.x remains recommended for production use. It is in maintenance mode and continues to receive critical bug fixes and security patches. - - -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg -[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ -[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg -[protocol-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://modelcontextprotocol.io/specification/latest - -## Overview - -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events - -## Installation - -### Adding MCP to your python project - -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. - -If you haven't created a uv-managed project yet, create one: - - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` - - Then add MCP to your project dependencies: - - ```bash - uv add "mcp[cli]" - ``` - -Alternatively, for projects using pip for dependencies: - -```bash -pip install "mcp[cli]" -``` - -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` - -## Quickstart - -Let's create a simple MCP server that exposes a calculator tool and some data: - - -```python -""" -FastMCP quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/fastmcp_quickstart.py -""" - -from mcp.server.fastmcp import FastMCP - -# Create an MCP server -mcp = FastMCP("Demo", json_response=True) - - -# Add an addition tool -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/fastmcp_quickstart.py)_ - - -You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: - -```bash -uv run --with mcp examples/snippets/servers/fastmcp_quickstart.py -``` - -Then add it to Claude Code: - -```bash -claude mcp add --transport http my-server http://localhost:8000/mcp -``` - -Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: - -```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:8000/mcp`. - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Documentation - -- [Building Servers](docs/server.md) -- tools, resources, prompts, logging, completions, sampling, elicitation, transports, ASGI mounting -- [Writing Clients](docs/client.md) -- connecting to servers, using tools/resources/prompts, display utilities -- [Authorization](docs/authorization.md) -- OAuth 2.1, token verification, client authentication -- [Low-Level Server](docs/low-level-server.md) -- direct handler registration for advanced use cases -- [Protocol Features](docs/protocol.md) -- MCP primitives, server capabilities -- [Testing](docs/testing.md) -- in-memory transport testing with pytest -- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) -- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/src/agent_workflow/mcp/sdk/RELEASE.md b/src/agent_workflow/mcp/sdk/RELEASE.md deleted file mode 100644 index 6555a1c..0000000 --- a/src/agent_workflow/mcp/sdk/RELEASE.md +++ /dev/null @@ -1,13 +0,0 @@ -# Release Process - -## Bumping Dependencies - -1. Change dependency version in `pyproject.toml` -2. Upgrade lock with `uv lock --resolution lowest-direct` - -## Major or Minor Release - -Create a GitHub release via UI with the tag being `vX.Y.Z` where `X.Y.Z` is the version, -and the release title being the same. Then ask someone to review the release. - -The package version will be set automatically from the tag. diff --git a/src/agent_workflow/mcp/sdk/ROADMAP.md b/src/agent_workflow/mcp/sdk/ROADMAP.md deleted file mode 100644 index 9f9bb31..0000000 --- a/src/agent_workflow/mcp/sdk/ROADMAP.md +++ /dev/null @@ -1,22 +0,0 @@ -# Roadmap - -## Spec Implementation Tracking - -The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26). - -## Current Focus Areas - -### Next Spec Revision - -The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). Key areas expected in the next revision include extensions and stateless transports. - -The SDK has historically implemented spec changes promptly as they are finalized, with dedicated project boards tracking component-level progress for each revision. - -### v2 - -A major version of the SDK is in active development, tracked via [GitHub Project](https://github.com/orgs/modelcontextprotocol/projects/31). Target milestones: - -- **Alpha**: ~mid-March 2026 -- **Beta**: ~May 2026 - -The v2 release is planned to align with the next spec release, expected around mid-2026. diff --git a/src/agent_workflow/mcp/sdk/SECURITY.md b/src/agent_workflow/mcp/sdk/SECURITY.md deleted file mode 100644 index 6545156..0000000 --- a/src/agent_workflow/mcp/sdk/SECURITY.md +++ /dev/null @@ -1,15 +0,0 @@ -# Security Policy - -Thank you for helping us keep the SDKs and systems they interact with secure. - -## Reporting Security Issues - -This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. - -The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. - -Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). - -## Vulnerability Disclosure Program - -Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). diff --git a/src/agent_workflow/mcp/sdk/VERSIONING.md b/src/agent_workflow/mcp/sdk/VERSIONING.md deleted file mode 100644 index a89d4c3..0000000 --- a/src/agent_workflow/mcp/sdk/VERSIONING.md +++ /dev/null @@ -1,40 +0,0 @@ -# Versioning Policy - -The MCP Python SDK (`mcp`) follows [Semantic Versioning 2.0.0](https://semver.org/). - -## Version Format - -`MAJOR.MINOR.PATCH` - -- **MAJOR**: Incremented for breaking changes (see below). -- **MINOR**: Incremented for new features that are backward-compatible. -- **PATCH**: Incremented for backward-compatible bug fixes. - -## What Constitutes a Breaking Change - -The following changes are considered breaking and require a major version bump: - -- Removing or renaming a public API export (class, function, type, or constant). -- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). -- Removing or renaming a public type or dataclass/TypedDict field. -- Changing the behavior of an existing API in a way that breaks documented contracts. -- Dropping support for a Python version that is still receiving security updates. -- Removing support for a transport type. -- Changes to the MCP protocol version that require client/server code changes. - -The following are **not** considered breaking: - -- Adding new optional parameters to existing functions. -- Adding new exports, types, or classes. -- Adding new optional fields to existing types. -- Bug fixes that correct behavior to match documented intent. -- Internal refactoring that does not affect the public API. -- Adding support for new MCP spec features. -- Changes to dev dependencies or build tooling. - -## How Breaking Changes Are Communicated - -1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions. -2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `warnings.warn()` with `DeprecationWarning`, which surfaces warnings at runtime and through static analysis tooling. -3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update. -4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. diff --git a/src/agent_workflow/mcp/sdk/docs/api.md b/src/agent_workflow/mcp/sdk/docs/api.md deleted file mode 100644 index 3f696af..0000000 --- a/src/agent_workflow/mcp/sdk/docs/api.md +++ /dev/null @@ -1 +0,0 @@ -::: mcp diff --git a/src/agent_workflow/mcp/sdk/docs/authorization.md b/src/agent_workflow/mcp/sdk/docs/authorization.md deleted file mode 100644 index 171871e..0000000 --- a/src/agent_workflow/mcp/sdk/docs/authorization.md +++ /dev/null @@ -1,177 +0,0 @@ -# Authorization - -This page covers OAuth 2.1 authentication for both MCP servers and clients. - -## Server-Side Authentication - -Authentication can be used by servers that want to expose tools accessing protected resources. - -`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. - -MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp import FastMCP - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create FastMCP instance as a Resource Server -mcp = FastMCP( - "Weather Service", - json_response=True, - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/oauth_server.py)_ - - -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/servers/simple-auth). - -**Architecture:** - -- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server - -See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/src/mcp/server/auth/provider.py) for more details on implementing token validation. - -## Client-Side Authentication - -The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) for connecting to protected MCP servers: - - -```python -""" -Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> tuple[str, str | None]: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return params["code"][0], params.get("state", [None])[0] - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() -``` - -_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/oauth_client.py)_ - - -For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/clients/simple-auth-client). diff --git a/src/agent_workflow/mcp/sdk/docs/client.md b/src/agent_workflow/mcp/sdk/docs/client.md deleted file mode 100644 index 77c2729..0000000 --- a/src/agent_workflow/mcp/sdk/docs/client.md +++ /dev/null @@ -1,410 +0,0 @@ -# Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports): - - -```python -""" -cd to the `examples/snippets/clients` directory and run: - uv run client -""" - -import asyncio -import os - -from pydantic import AnyUrl - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client -from mcp.shared.context import RequestContext - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stopReason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from fastmcp_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextResourceContents): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from fastmcp_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structuredContent - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/stdio_client.py)_ - - -Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http): - - -```python -""" -Run from the repository root: - uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as ( - read_stream, - write_stream, - _, - ): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/streamable_basic.py)_ - - -## Client Display Utilities - -When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: - - -```python -""" -cd to the `examples/snippets` directory and run: - uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resourceTemplates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/display_utilities.py)_ - - -The `get_display_name()` function implements the proper precedence rules for displaying names: - -- For tools: `title` > `annotations.title` > `name` -- For other objects: `title` > `name` - -This ensures your client UI shows the most user-friendly names that servers provide. - -## OAuth Authentication - -For OAuth 2.1 client authentication, see [Authorization](authorization.md#client-side-authentication). - -## Roots - -### Listing Roots - -Clients can provide a `list_roots_callback` so that servers can discover the client's workspace roots (directories, project folders, etc.): - - -```python -from mcp import ClientSession, types -from mcp.shared.context import RequestContext - - -async def handle_list_roots( - context: RequestContext[ClientSession, None], -) -> types.ListRootsResult: - """Return the client's workspace roots.""" - return types.ListRootsResult( - roots=[ - types.Root(uri="file:///home/user/project", name="My Project"), - types.Root(uri="file:///home/user/data", name="Data Folder"), - ] - ) - - -# Pass the callback when creating the session -session = ClientSession( - read_stream, - write_stream, - list_roots_callback=handle_list_roots, -) -``` - -_Full example: [examples/snippets/clients/roots_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/roots_example.py)_ - - -When a `list_roots_callback` is provided, the client automatically declares the `roots` capability (with `listChanged=True`) during initialization. - -### Roots Change Notifications - -When the client's workspace roots change (e.g., a folder is added or removed), notify the server: - -```python -# After roots change, notify the server -await session.send_roots_list_changed() -``` - -## SSE Transport (Legacy) - -For servers that use the older SSE transport, use `sse_client()` from `mcp.client.sse`: - - -```python -import asyncio - -from mcp import ClientSession -from mcp.client.sse import sse_client - - -async def main(): - async with sse_client("http://localhost:8000/sse") as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - -asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/sse_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/sse_client.py)_ - - -The `sse_client()` function accepts optional `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters. The SSE transport is considered legacy; prefer [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) for new servers. - -## Ping - -Send a ping to verify the server is responsive: - -```python -# After session.initialize() -result = await session.send_ping() -# Returns EmptyResult on success; raises on timeout -``` - -## Logging - -### Receiving Log Messages - -Pass a `logging_callback` to receive log messages from the server: - - -```python -from mcp import ClientSession, types - - -async def handle_log(params: types.LoggingMessageNotificationParams) -> None: - """Handle log messages from the server.""" - print(f"[{params.level}] {params.data}") - - -session = ClientSession( - read_stream, - write_stream, - logging_callback=handle_log, -) -``` - -_Full example: [examples/snippets/clients/logging_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/logging_client.py)_ - - -### Setting the Server Log Level - -Request that the server change its minimum log level: - -```python -await session.set_logging_level("debug") -``` - -The `level` parameter is a `LoggingLevel` string: `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`. - -## Parsing Tool Results - -When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. - -```python -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters( - command="python", args=["path/to/mcp_server.py"] - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structuredContent") and result.structuredContent: - # Access structured data directly - user_data = result.structuredContent - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - elif isinstance(resource, types.BlobResourceContents): - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mimeType}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.isError: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) -``` diff --git a/src/agent_workflow/mcp/sdk/docs/experimental/index.md b/src/agent_workflow/mcp/sdk/docs/experimental/index.md deleted file mode 100644 index 3b7f113..0000000 --- a/src/agent_workflow/mcp/sdk/docs/experimental/index.md +++ /dev/null @@ -1,46 +0,0 @@ -# Experimental Features - -!!! warning "Deprecated" - - The experimental tasks API is deprecated and will be removed in mcp 2.0. - Tasks (SEP-1686) were removed from the MCP specification and are expected - to return as a separate MCP extension in a future release. - -This section documents experimental features in the MCP Python SDK. These features -are deprecated and remain available on the 1.x line only for existing users. - -## Available Experimental Features - -### [Tasks](tasks.md) - -Tasks enable asynchronous execution of MCP operations. Instead of waiting for a -long-running operation to complete, the server returns a task reference immediately. -Clients can then poll for status updates and retrieve results when ready. - -Tasks are useful for: - -- **Long-running computations** that would otherwise block -- **Batch operations** that process many items -- **Interactive workflows** that require user input (elicitation) or LLM assistance (sampling) - -## Using Experimental APIs - -Experimental features are accessed via the `.experimental` property: - -```python -# Server-side -@server.experimental.get_task() -async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: - ... - -# Client-side -result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) -``` - -Accessing the `.experimental` properties emits a `DeprecationWarning`. - -## Providing Feedback - -If you rely on these features and have feedback on their deprecation or the planned -MCP extension, please open an issue on the -[python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues). diff --git a/src/agent_workflow/mcp/sdk/docs/experimental/tasks-client.md b/src/agent_workflow/mcp/sdk/docs/experimental/tasks-client.md deleted file mode 100644 index acc8392..0000000 --- a/src/agent_workflow/mcp/sdk/docs/experimental/tasks-client.md +++ /dev/null @@ -1,363 +0,0 @@ -# Client Task Usage - -!!! warning "Deprecated" - - The experimental tasks API is deprecated and will be removed in mcp 2.0. - Tasks (SEP-1686) were removed from the MCP specification and are expected - to return as a separate MCP extension in a future release. - -This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers. - -## Quick Start - -Call a tool as a task and poll for the result: - -```python -from mcp.client.session import ClientSession -from mcp.types import CallToolResult - -async with ClientSession(read, write) as session: - await session.initialize() - - # Call tool as task - result = await session.experimental.call_tool_as_task( - "process_data", - {"input": "hello"}, - ttl=60000, - ) - task_id = result.task.taskId - - # Poll until complete - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status} - {status.statusMessage or ''}") - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) - print(f"Result: {final.content[0].text}") -``` - -## Calling Tools as Tasks - -Use `call_tool_as_task()` to invoke a tool with task augmentation: - -```python -result = await session.experimental.call_tool_as_task( - "my_tool", # Tool name - {"arg": "value"}, # Arguments - ttl=60000, # Time-to-live in milliseconds - meta={"key": "val"}, # Optional metadata -) - -task_id = result.task.taskId -print(f"Task: {task_id}, Status: {result.task.status}") -``` - -The response is a `CreateTaskResult` containing: - -- `task.taskId` - Unique identifier for polling -- `task.status` - Initial status (usually `"working"`) -- `task.pollInterval` - Suggested polling interval (milliseconds) -- `task.ttl` - Time-to-live for results -- `task.createdAt` - Creation timestamp - -## Polling with poll_task - -The `poll_task()` async iterator polls until the task reaches a terminal state: - -```python -async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - if status.statusMessage: - print(f"Progress: {status.statusMessage}") -``` - -It automatically: - -- Respects the server's suggested `pollInterval` -- Stops when status is `completed`, `failed`, or `cancelled` -- Yields each status for progress display - -### Handling input_required - -When a task needs user input (elicitation), it transitions to `input_required`. You must call `get_task_result()` to receive and respond to the elicitation: - -```python -async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - - if status.status == "input_required": - # This delivers the elicitation and waits for completion - final = await session.experimental.get_task_result(task_id, CallToolResult) - break -``` - -The elicitation callback (set during session creation) handles the actual user interaction. - -## Elicitation Callbacks - -To handle elicitation requests from the server, provide a callback when creating the session: - -```python -from mcp.types import ElicitRequestParams, ElicitResult - -async def handle_elicitation(context, params: ElicitRequestParams) -> ElicitResult: - # Display the message to the user - print(f"Server asks: {params.message}") - - # Collect user input (this is a simplified example) - response = input("Your response (y/n): ") - confirmed = response.lower() == "y" - - return ElicitResult( - action="accept", - content={"confirm": confirmed}, - ) - -async with ClientSession( - read, - write, - elicitation_callback=handle_elicitation, -) as session: - await session.initialize() - # ... call tasks that may require elicitation -``` - -## Sampling Callbacks - -Similarly, handle sampling requests with a callback: - -```python -from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent - -async def handle_sampling(context, params: CreateMessageRequestParams) -> CreateMessageResult: - # In a real implementation, call your LLM here - prompt = params.messages[-1].content.text if params.messages else "" - - # Return a mock response - return CreateMessageResult( - role="assistant", - content=TextContent(type="text", text=f"Response to: {prompt}"), - model="my-model", - ) - -async with ClientSession( - read, - write, - sampling_callback=handle_sampling, -) as session: - # ... -``` - -## Retrieving Results - -Once a task completes, retrieve the result: - -```python -if status.status == "completed": - result = await session.experimental.get_task_result(task_id, CallToolResult) - for content in result.content: - if hasattr(content, "text"): - print(content.text) - -elif status.status == "failed": - print(f"Task failed: {status.statusMessage}") - -elif status.status == "cancelled": - print("Task was cancelled") -``` - -The result type matches the original request: - -- `tools/call` → `CallToolResult` -- `sampling/createMessage` → `CreateMessageResult` -- `elicitation/create` → `ElicitResult` - -## Cancellation - -Cancel a running task: - -```python -cancel_result = await session.experimental.cancel_task(task_id) -print(f"Cancelled, status: {cancel_result.status}") -``` - -Note: Cancellation is cooperative—the server must check for and handle cancellation. - -## Listing Tasks - -View all tasks on the server: - -```python -result = await session.experimental.list_tasks() -for task in result.tasks: - print(f"{task.taskId}: {task.status}") - -# Handle pagination -while result.nextCursor: - result = await session.experimental.list_tasks(cursor=result.nextCursor) - for task in result.tasks: - print(f"{task.taskId}: {task.status}") -``` - -## Advanced: Client as Task Receiver - -Servers can send task-augmented requests to clients. This is useful when the server needs the client to perform async work (like complex sampling or user interaction). - -### Declaring Client Capabilities - -Register task handlers to declare what task-augmented requests your client accepts: - -```python -from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers -from mcp.types import ( - CreateTaskResult, GetTaskResult, GetTaskPayloadResult, - TaskMetadata, ElicitRequestParams, -) -from mcp.shared.experimental.tasks import InMemoryTaskStore - -# Client-side task store -client_store = InMemoryTaskStore() - -async def handle_augmented_elicitation(context, params: ElicitRequestParams, task_metadata: TaskMetadata): - """Handle task-augmented elicitation from server.""" - # Create a task for this elicitation - task = await client_store.create_task(task_metadata) - - # Start async work (e.g., show UI, wait for user) - async def complete_elicitation(): - # ... do async work ... - result = ElicitResult(action="accept", content={"confirm": True}) - await client_store.store_result(task.taskId, result) - await client_store.update_task(task.taskId, status="completed") - - context.session._task_group.start_soon(complete_elicitation) - - # Return task reference immediately - return CreateTaskResult(task=task) - -async def handle_get_task(context, params): - """Handle tasks/get from server.""" - task = await client_store.get_task(params.taskId) - return GetTaskResult( - taskId=task.taskId, - status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, - ttl=task.ttl, - pollInterval=100, - ) - -async def handle_get_task_result(context, params): - """Handle tasks/result from server.""" - result = await client_store.get_result(params.taskId) - return GetTaskPayloadResult.model_validate(result.model_dump()) - -task_handlers = ExperimentalTaskHandlers( - augmented_elicitation=handle_augmented_elicitation, - get_task=handle_get_task, - get_task_result=handle_get_task_result, -) - -async with ClientSession( - read, - write, - experimental_task_handlers=task_handlers, -) as session: - # Client now accepts task-augmented elicitation from server - await session.initialize() -``` - -This enables flows where: - -1. Client calls a task-augmented tool -2. Server's tool work calls `task.elicit_as_task()` -3. Client receives task-augmented elicitation -4. Client creates its own task, does async work -5. Server polls client's task -6. Eventually both tasks complete - -## Complete Example - -A client that handles all task scenarios: - -```python -import anyio -from mcp.client.session import ClientSession -from mcp.client.stdio import stdio_client -from mcp.types import CallToolResult, ElicitRequestParams, ElicitResult - - -async def elicitation_callback(context, params: ElicitRequestParams) -> ElicitResult: - print(f"\n[Elicitation] {params.message}") - response = input("Confirm? (y/n): ") - return ElicitResult(action="accept", content={"confirm": response.lower() == "y"}) - - -async def main(): - async with stdio_client(command="python", args=["server.py"]) as (read, write): - async with ClientSession( - read, - write, - elicitation_callback=elicitation_callback, - ) as session: - await session.initialize() - - # List available tools - tools = await session.list_tools() - print("Tools:", [t.name for t in tools.tools]) - - # Call a task-augmented tool - print("\nCalling task tool...") - result = await session.experimental.call_tool_as_task( - "confirm_action", - {"action": "delete files"}, - ) - task_id = result.task.taskId - print(f"Task created: {task_id}") - - # Poll and handle input_required - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - - if status.status == "input_required": - final = await session.experimental.get_task_result(task_id, CallToolResult) - print(f"Result: {final.content[0].text}") - break - - if status.status == "completed": - final = await session.experimental.get_task_result(task_id, CallToolResult) - print(f"Result: {final.content[0].text}") - - -if __name__ == "__main__": - anyio.run(main) -``` - -## Error Handling - -Handle task errors gracefully: - -```python -from mcp.shared.exceptions import McpError - -try: - result = await session.experimental.call_tool_as_task("my_tool", args) - task_id = result.task.taskId - - async for status in session.experimental.poll_task(task_id): - if status.status == "failed": - raise RuntimeError(f"Task failed: {status.statusMessage}") - - final = await session.experimental.get_task_result(task_id, CallToolResult) - -except McpError as e: - print(f"MCP error: {e.error.message}") -except Exception as e: - print(f"Error: {e}") -``` - -## Next Steps - -- [Server Implementation](tasks-server.md) - Build task-supporting servers -- [Tasks Overview](tasks.md) - Review lifecycle and concepts diff --git a/src/agent_workflow/mcp/sdk/docs/experimental/tasks-server.md b/src/agent_workflow/mcp/sdk/docs/experimental/tasks-server.md deleted file mode 100644 index 1760e19..0000000 --- a/src/agent_workflow/mcp/sdk/docs/experimental/tasks-server.md +++ /dev/null @@ -1,622 +0,0 @@ -# Server Task Implementation - -!!! warning "Deprecated" - - The experimental tasks API is deprecated and will be removed in mcp 2.0. - Tasks (SEP-1686) were removed from the MCP specification and are expected - to return as a separate MCP extension in a future release. - -This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks. - -## Quick Start - -The simplest way to add task support: - -```python -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED - -server = Server("my-server") -server.experimental.enable_tasks() # Registers all task handlers automatically - -@server.list_tools() -async def list_tools(): - return [ - Tool( - name="process_data", - description="Process data asynchronously", - inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: - if name == "process_data": - return await handle_process_data(arguments) - return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) - -async def handle_process_data(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Processing...") - result = arguments.get("input", "").upper() - return CallToolResult(content=[TextContent(type="text", text=result)]) - - return await ctx.experimental.run_task(work) -``` - -That's it. `enable_tasks()` automatically: - -- Creates an in-memory task store -- Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel` -- Updates server capabilities - -## Task Visibility - -Task IDs generated by `run_task()` embed an opaque marker identifying the session that -created the task, and the default handlers use it to restrict each session to its own -tasks: `tasks/get`, `tasks/result`, and `tasks/cancel` respond with "task not found" for -another session's task, and `tasks/list` returns only the requesting session's tasks. A -client that reconnects gets a new session and can no longer reach tasks it created on the -previous one. - -A task ID has no session marker when it was passed to `run_task()` explicitly, when the -task was created directly through the `TaskStore`, or when the server runs in stateless -mode (each request gets a fresh session, so tasks must remain reachable across requests). -Such tasks are accessible to any requestor that presents the exact task ID, and are never -included in `tasks/list` responses because the server cannot tell which session they -belong to. Treat these task IDs as capabilities: generate them with enough entropy that -they cannot be guessed, share them only with the intended recipient, and prefer short -TTLs. Passing an explicit `task_id` to `run_task()` is deprecated for this reason. - -To scope tasks to something other than the session — for example a user identity from your -authorization layer — register your own handlers with `@server.experimental.get_task()`, -`@server.experimental.get_task_result()`, `@server.experimental.list_tasks()`, and -`@server.experimental.cancel_task()` instead of relying on the defaults. - -## Tool Declaration - -Tools declare task support via the `execution.taskSupport` field: - -```python -from mcp.types import Tool, ToolExecution, TASK_REQUIRED, TASK_OPTIONAL, TASK_FORBIDDEN - -Tool( - name="my_tool", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), # or TASK_OPTIONAL, TASK_FORBIDDEN -) -``` - -| Value | Meaning | -|-------|---------| -| `TASK_REQUIRED` | Tool **must** be called as a task | -| `TASK_OPTIONAL` | Tool supports both sync and task execution | -| `TASK_FORBIDDEN` | Tool **cannot** be called as a task (default) | - -Validate the request matches your tool's requirements: - -```python -@server.call_tool() -async def handle_tool(name: str, arguments: dict): - ctx = server.request_context - - if name == "required_task_tool": - ctx.experimental.validate_task_mode(TASK_REQUIRED) # Raises if not task mode - return await handle_as_task(arguments) - - elif name == "optional_task_tool": - if ctx.experimental.is_task: - return await handle_as_task(arguments) - else: - return handle_sync(arguments) -``` - -## The run_task Pattern - -`run_task()` is the recommended way to execute task work: - -```python -async def handle_my_tool(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - async def work(task: ServerTaskContext) -> CallToolResult: - # Your work here - return CallToolResult(content=[TextContent(type="text", text="Done")]) - - return await ctx.experimental.run_task(work) -``` - -**What `run_task()` does:** - -1. Creates a task in the store -2. Spawns your work function in the background -3. Returns `CreateTaskResult` immediately -4. Auto-completes the task when your function returns -5. Auto-fails the task if your function raises - -**The `ServerTaskContext` provides:** - -- `task.task_id` - The task identifier -- `task.update_status(message)` - Update progress -- `task.complete(result)` - Explicitly complete (usually automatic) -- `task.fail(error)` - Explicitly fail -- `task.is_cancelled` - Check if cancellation requested - -## Status Updates - -Keep clients informed of progress: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Starting...") - - for i, item in enumerate(items): - await task.update_status(f"Processing {i+1}/{len(items)}") - await process_item(item) - - await task.update_status("Finalizing...") - return CallToolResult(content=[TextContent(type="text", text="Complete")]) -``` - -Status messages appear in `tasks/get` responses, letting clients show progress to users. - -## Elicitation Within Tasks - -Tasks can request user input via elicitation. This transitions the task to `input_required` status. - -### Form Elicitation - -Collect structured data from the user: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Waiting for confirmation...") - - result = await task.elicit( - message="Delete these files?", - requestedSchema={ - "type": "object", - "properties": { - "confirm": {"type": "boolean"}, - "reason": {"type": "string"}, - }, - "required": ["confirm"], - }, - ) - - if result.action == "accept" and result.content.get("confirm"): - # User confirmed - return CallToolResult(content=[TextContent(type="text", text="Files deleted")]) - else: - # User declined or cancelled - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) -``` - -### URL Elicitation - -Direct users to external URLs for OAuth, payments, or other out-of-band flows: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Waiting for OAuth...") - - result = await task.elicit_url( - message="Please authorize with GitHub", - url="https://github.com/login/oauth/authorize?client_id=...", - elicitation_id="oauth-github-123", - ) - - if result.action == "accept": - # User completed OAuth flow - return CallToolResult(content=[TextContent(type="text", text="Connected to GitHub")]) - else: - return CallToolResult(content=[TextContent(type="text", text="OAuth cancelled")]) -``` - -## Sampling Within Tasks - -Tasks can request LLM completions from the client: - -```python -from mcp.types import SamplingMessage, TextContent - -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Generating response...") - - result = await task.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text="Write a haiku about coding"), - ) - ], - max_tokens=100, - ) - - haiku = result.content.text if isinstance(result.content, TextContent) else "Error" - return CallToolResult(content=[TextContent(type="text", text=haiku)]) -``` - -Sampling supports additional parameters: - -```python -result = await task.create_message( - messages=[...], - max_tokens=500, - system_prompt="You are a helpful assistant", - temperature=0.7, - stop_sequences=["\n\n"], - model_preferences=ModelPreferences(hints=[ModelHint(name="claude-3")]), -) -``` - -## Cancellation Support - -Check for cancellation in long-running work: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - for i in range(1000): - if task.is_cancelled: - # Clean up and exit - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) - - await task.update_status(f"Step {i}/1000") - await process_step(i) - - return CallToolResult(content=[TextContent(type="text", text="Complete")]) -``` - -The SDK's default cancel handler updates the task status. Your work function should check `is_cancelled` periodically. - -## Custom Task Store - -For production, implement `TaskStore` with persistent storage: - -```python -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import Task, TaskMetadata, Result - -class RedisTaskStore(TaskStore): - def __init__(self, redis_client): - self.redis = redis_client - - async def create_task(self, metadata: TaskMetadata, task_id: str | None = None) -> Task: - # Create and persist task - ... - - async def get_task(self, task_id: str) -> Task | None: - # Retrieve task from Redis - ... - - async def update_task(self, task_id: str, status: str | None = None, ...) -> Task: - # Update and persist - ... - - async def store_result(self, task_id: str, result: Result) -> None: - # Store result in Redis - ... - - async def get_result(self, task_id: str) -> Result | None: - # Retrieve result - ... - - # ... implement remaining methods -``` - -Use your custom store: - -```python -store = RedisTaskStore(redis_client) -server.experimental.enable_tasks(store=store) -``` - -## Complete Example - -A server with multiple task-supporting tools: - -```python -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import ( - CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, - SamplingMessage, TASK_REQUIRED, -) - -server = Server("task-demo") -server.experimental.enable_tasks() - - -@server.list_tools() -async def list_tools(): - return [ - Tool( - name="confirm_action", - description="Requires user confirmation", - inputSchema={"type": "object", "properties": {"action": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ), - Tool( - name="generate_text", - description="Generate text via LLM", - inputSchema={"type": "object", "properties": {"prompt": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ), - ] - - -async def handle_confirm_action(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - action = arguments.get("action", "unknown action") - - async def work(task: ServerTaskContext) -> CallToolResult: - result = await task.elicit( - message=f"Confirm: {action}?", - requestedSchema={ - "type": "object", - "properties": {"confirm": {"type": "boolean"}}, - "required": ["confirm"], - }, - ) - - if result.action == "accept" and result.content.get("confirm"): - return CallToolResult(content=[TextContent(type="text", text=f"Executed: {action}")]) - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) - - return await ctx.experimental.run_task(work) - - -async def handle_generate_text(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - prompt = arguments.get("prompt", "Hello") - - async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Generating...") - - result = await task.create_message( - messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], - max_tokens=200, - ) - - text = result.content.text if isinstance(result.content, TextContent) else "Error" - return CallToolResult(content=[TextContent(type="text", text=text)]) - - return await ctx.experimental.run_task(work) - - -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: - if name == "confirm_action": - return await handle_confirm_action(arguments) - elif name == "generate_text": - return await handle_generate_text(arguments) - return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) -``` - -## Error Handling in Tasks - -Tasks handle errors automatically, but you can also fail explicitly: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - try: - result = await risky_operation() - return CallToolResult(content=[TextContent(type="text", text=result)]) - except PermissionError: - await task.fail("Access denied - insufficient permissions") - raise - except TimeoutError: - await task.fail("Operation timed out after 30 seconds") - raise -``` - -When `run_task()` catches an exception, it automatically: - -1. Marks the task as `failed` -2. Sets `statusMessage` to the exception message -3. Propagates the exception (which is caught by the task group) - -For custom error messages, call `task.fail()` before raising. - -## HTTP Transport Example - -For web applications, use the Streamable HTTP transport: - -```python -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -import uvicorn -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from mcp.types import ( - CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED, -) - - -server = Server("http-task-server") -server.experimental.enable_tasks() - - -@server.list_tools() -async def list_tools(): - return [ - Tool( - name="long_operation", - description="A long-running operation", - inputSchema={"type": "object", "properties": {"duration": {"type": "number"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - -async def handle_long_operation(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - duration = arguments.get("duration", 5) - - async def work(task: ServerTaskContext) -> CallToolResult: - import anyio - for i in range(int(duration)): - await task.update_status(f"Step {i+1}/{int(duration)}") - await anyio.sleep(1) - return CallToolResult(content=[TextContent(type="text", text=f"Completed after {duration}s")]) - - return await ctx.experimental.run_task(work) - - -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: - if name == "long_operation": - return await handle_long_operation(arguments) - return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) - - -def create_app(): - session_manager = StreamableHTTPSessionManager(app=server) - - @asynccontextmanager - async def lifespan(app: Starlette) -> AsyncIterator[None]: - async with session_manager.run(): - yield - - return Starlette( - routes=[Mount("/mcp", app=session_manager.handle_request)], - lifespan=lifespan, - ) - - -if __name__ == "__main__": - uvicorn.run(create_app(), host="127.0.0.1", port=8000) -``` - -## Testing Task Servers - -Test task functionality with the SDK's testing utilities: - -```python -import pytest -import anyio -from mcp.client.session import ClientSession -from mcp.types import CallToolResult - - -@pytest.mark.anyio -async def test_task_tool(): - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream(10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream(10) - - async def run_server(): - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options(), - ) - - async def run_client(): - async with ClientSession(server_to_client_receive, client_to_server_send) as session: - await session.initialize() - - # Call the tool as a task - result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) - task_id = result.task.taskId - assert result.task.status == "working" - - # Poll until complete - async for status in session.experimental.poll_task(task_id): - if status.status in ("completed", "failed"): - break - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) - assert len(final.content) > 0 - - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) -``` - -## Best Practices - -### Keep Work Functions Focused - -```python -# Good: focused work function -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Validating...") - validate_input(arguments) - - await task.update_status("Processing...") - result = await process_data(arguments) - - return CallToolResult(content=[TextContent(type="text", text=result)]) -``` - -### Check Cancellation in Loops - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - results = [] - for item in large_dataset: - if task.is_cancelled: - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) - - results.append(await process(item)) - - return CallToolResult(content=[TextContent(type="text", text=str(results))]) -``` - -### Use Meaningful Status Messages - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Connecting to database...") - db = await connect() - - await task.update_status("Fetching records (0/1000)...") - for i, record in enumerate(records): - if i % 100 == 0: - await task.update_status(f"Processing records ({i}/1000)...") - await process(record) - - await task.update_status("Finalizing results...") - return CallToolResult(content=[TextContent(type="text", text="Done")]) -``` - -### Handle Elicitation Responses - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - result = await task.elicit(message="Continue?", requestedSchema={...}) - - match result.action: - case "accept": - # User accepted, process content - return await process_accepted(result.content) - case "decline": - # User explicitly declined - return CallToolResult(content=[TextContent(type="text", text="User declined")]) - case "cancel": - # User cancelled the elicitation - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) -``` - -## Next Steps - -- [Client Usage](tasks-client.md) - Learn how clients interact with task servers -- [Tasks Overview](tasks.md) - Review lifecycle and concepts diff --git a/src/agent_workflow/mcp/sdk/docs/experimental/tasks.md b/src/agent_workflow/mcp/sdk/docs/experimental/tasks.md deleted file mode 100644 index e5dd78f..0000000 --- a/src/agent_workflow/mcp/sdk/docs/experimental/tasks.md +++ /dev/null @@ -1,189 +0,0 @@ -# Tasks - -!!! warning "Deprecated" - - The experimental tasks API is deprecated and will be removed in mcp 2.0. - Tasks (SEP-1686) were removed from the MCP specification and are expected - to return as a separate MCP extension in a future release. - -Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result. - -## When to Use Tasks - -Tasks are designed for operations that: - -- Take significant time (seconds to minutes) -- Need progress updates during execution -- Require user input mid-execution (elicitation, sampling) -- Should run without blocking the requestor - -Common use cases: - -- Long-running data processing -- Multi-step workflows with user confirmation -- LLM-powered operations requiring sampling -- OAuth flows requiring user browser interaction - -## Task Lifecycle - -```text - ┌─────────────┐ - │ working │ - └──────┬──────┘ - │ - ┌────────────┼────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌────────────┐ ┌───────────┐ ┌───────────┐ - │ completed │ │ failed │ │ cancelled │ - └────────────┘ └───────────┘ └───────────┘ - ▲ - │ - ┌────────┴────────┐ - │ input_required │◄──────┐ - └────────┬────────┘ │ - │ │ - └────────────────┘ -``` - -| Status | Description | -|--------|-------------| -| `working` | Task is being processed | -| `input_required` | Receiver needs input from requestor (elicitation/sampling) | -| `completed` | Task finished successfully | -| `failed` | Task encountered an error | -| `cancelled` | Task was cancelled by requestor | - -Terminal states (`completed`, `failed`, `cancelled`) are final—tasks cannot transition out of them. - -## Bidirectional Flow - -Tasks work in both directions: - -**Client → Server** (most common): - -```text -Client Server - │ │ - │── tools/call (task) ──────────────>│ Creates task - │<── CreateTaskResult ───────────────│ - │ │ - │── tasks/get ──────────────────────>│ - │<── status: working ────────────────│ - │ │ ... work continues ... - │── tasks/get ──────────────────────>│ - │<── status: completed ──────────────│ - │ │ - │── tasks/result ───────────────────>│ - │<── CallToolResult ─────────────────│ -``` - -**Server → Client** (for elicitation/sampling): - -```text -Server Client - │ │ - │── elicitation/create (task) ──────>│ Creates task - │<── CreateTaskResult ───────────────│ - │ │ - │── tasks/get ──────────────────────>│ - │<── status: working ────────────────│ - │ │ ... user interaction ... - │── tasks/get ──────────────────────>│ - │<── status: completed ──────────────│ - │ │ - │── tasks/result ───────────────────>│ - │<── ElicitResult ───────────────────│ -``` - -## Key Concepts - -### Task Metadata - -When augmenting a request with task execution, include `TaskMetadata`: - -```python -from mcp.types import TaskMetadata - -task = TaskMetadata(ttl=60000) # TTL in milliseconds -``` - -The `ttl` (time-to-live) specifies how long the task and result are retained after completion. - -### Task Store - -Servers persist task state in a `TaskStore`. The SDK provides `InMemoryTaskStore` for development: - -```python -from mcp.shared.experimental.tasks import InMemoryTaskStore - -store = InMemoryTaskStore() -``` - -For production, implement `TaskStore` with a database or distributed cache. - -### Capabilities - -Both servers and clients declare task support through capabilities: - -**Server capabilities:** - -- `tasks.requests.tools.call` - Server accepts task-augmented tool calls - -**Client capabilities:** - -- `tasks.requests.sampling.createMessage` - Client accepts task-augmented sampling -- `tasks.requests.elicitation.create` - Client accepts task-augmented elicitation - -The SDK manages these automatically when you enable task support. - -## Quick Example - -**Server** (simplified API): - -```python -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import CallToolResult, TextContent, TASK_REQUIRED - -server = Server("my-server") -server.experimental.enable_tasks() # One-line setup - -@server.call_tool() -async def handle_tool(name: str, arguments: dict): - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - async def work(task: ServerTaskContext): - await task.update_status("Processing...") - # ... do work ... - return CallToolResult(content=[TextContent(type="text", text="Done!")]) - - return await ctx.experimental.run_task(work) -``` - -**Client:** - -```python -from mcp.client.session import ClientSession -from mcp.types import CallToolResult - -async with ClientSession(read, write) as session: - await session.initialize() - - # Call tool as task - result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) - task_id = result.task.taskId - - # Poll until done - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) -``` - -## Next Steps - -- [Server Implementation](tasks-server.md) - Build task-supporting servers -- [Client Usage](tasks-client.md) - Call and poll tasks from clients diff --git a/src/agent_workflow/mcp/sdk/docs/index.md b/src/agent_workflow/mcp/sdk/docs/index.md deleted file mode 100644 index 48f3ace..0000000 --- a/src/agent_workflow/mcp/sdk/docs/index.md +++ /dev/null @@ -1,72 +0,0 @@ -# MCP Python SDK - -!!! tip "Looking for the upcoming v2?" - See the [v2 development documentation](https://py.sdk.modelcontextprotocol.io/v2/). - -The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. - -This Python SDK implements the full MCP specification, making it easy to: - -- **Build MCP servers** that expose resources, prompts, and tools -- **Create MCP clients** that can connect to any MCP server -- **Use standard transports** like stdio, SSE, and Streamable HTTP - -If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io). - -## Quick Example - -Here's a simple MCP server that exposes a tool, resource, and prompt: - -```python title="server.py" -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Test Server", json_response=True) - - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - return f"Write a {style} greeting for someone named {name}." - - -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -Run the server: - -```bash -uv run --with mcp server.py -``` - -Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`: - -```bash -npx -y @modelcontextprotocol/inspector -``` - -## Getting Started - - -1. **[Install](installation.md)** the MCP SDK -2. **[Build servers](server.md)** - tools, resources, prompts, transports, ASGI mounting -3. **[Write clients](client.md)** - connect to servers, use tools/resources/prompts -4. **[Explore authorization](authorization.md)** - add security to your servers -5. **[Use low-level APIs](low-level-server.md)** - for advanced customization -6. **[Protocol features](protocol.md)** - MCP primitives, server capabilities - -## API Reference - -Full API documentation is available in the [API Reference](api.md). diff --git a/src/agent_workflow/mcp/sdk/docs/installation.md b/src/agent_workflow/mcp/sdk/docs/installation.md deleted file mode 100644 index 6e20706..0000000 --- a/src/agent_workflow/mcp/sdk/docs/installation.md +++ /dev/null @@ -1,31 +0,0 @@ -# Installation - -The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as: - -=== "pip" - - ```bash - pip install mcp - ``` -=== "uv" - - ```bash - uv add mcp - ``` - -The following dependencies are automatically installed: - -- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. -- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. -- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). -- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. -- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. -- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. -- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in FastMCP. -- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. -- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. -- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. - -This package has the following optional groups: - -- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. diff --git a/src/agent_workflow/mcp/sdk/docs/low-level-server.md b/src/agent_workflow/mcp/sdk/docs/low-level-server.md deleted file mode 100644 index 27547e7..0000000 --- a/src/agent_workflow/mcp/sdk/docs/low-level-server.md +++ /dev/null @@ -1,490 +0,0 @@ -# Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API. - -## Lifespan - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -@asynccontextmanager -async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: - """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup - db = await Database.connect() - try: - yield {"db": db} - finally: - # Clean up on shutdown - await db.disconnect() - - -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) - - -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="query_db", - description="Query the database", - inputSchema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - - -@server.call_tool() -async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: - """Handle database query tool call.""" - if name != "query_db": - raise ValueError(f"Unknown tool: {name}") - - # Access lifespan context - ctx = server.request_context - db = ctx.lifespan_context["db"] - - # Execute query - results = await db.query(arguments["query"]) - - return [types.TextContent(type="text", text=f"Query results: {results}")] - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example-server", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/lifespan.py)_ - - -The lifespan API provides: - -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - -## Basic Example - - -```python -""" -Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create a server instance -server = Server("example-server") - - -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: - """List available prompts.""" - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - - -@server.get_prompt() -async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") - - arg1_value = (arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/basic.py)_ - - -Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. - -## Structured Output Support - -The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools with structured output schemas.""" - return [ - types.Tool( - name="get_weather", - description="Get current weather for a city", - inputSchema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - outputSchema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - - -@server.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - """Handle tool calls with structured output.""" - if name == "get_weather": - city = arguments["city"] - - # Simulated weather data - in production, call a weather API - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, # Include the requested city - } - - # low-level server will validate structured output against the tool's - # output schema, and additionally serialize it into a TextContent block - # for backwards compatibility with pre-2025-06-18 clients. - return weather_data - else: - raise ValueError(f"Unknown tool: {name}") - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="structured-output-example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/structured_output.py)_ - - -Tools can return data in four ways: - -1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) -2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) -3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility -4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) - -When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. - -### Returning CallToolResult Directly - -For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - inputSchema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if name == "advanced_tool": - message = str(arguments.get("message", "")) - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structuredContent={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {name}") - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ - - -**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. - -## Pagination (Advanced) - -For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. - -### Server-side Implementation - - -```python -""" -Example of implementing pagination with MCP server decorators. -""" - -from pydantic import AnyUrl - -import mcp.types as types -from mcp.server.lowlevel import Server - -# Initialize the server -server = Server("paginated-server") - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -@server.list_resources() -async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = request.params.cursor if request.params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) -``` - -_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/pagination_example.py)_ - - -### Client-side Consumption - - -```python -""" -Example of consuming paginated MCP endpoints from a client. -""" - -import asyncio - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import PaginatedRequestParams, Resource - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.nextCursor: - cursor = result.nextCursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) -``` - -_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/pagination_client.py)_ - - -### Key Points - -- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) -- **Return `nextCursor=None`** when there are no more pages -- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) -- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics - -See the [simple-pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/servers/simple-pagination) for a complete implementation. diff --git a/src/agent_workflow/mcp/sdk/docs/protocol.md b/src/agent_workflow/mcp/sdk/docs/protocol.md deleted file mode 100644 index 2c4604d..0000000 --- a/src/agent_workflow/mcp/sdk/docs/protocol.md +++ /dev/null @@ -1,153 +0,0 @@ -# Protocol Features - -This page covers cross-cutting MCP protocol features. - -## MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -## Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|--------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completions`| - | Argument completion suggestions | - -## Ping - -Both clients and servers can send ping requests to check that the other side is responsive: - -```python -# From a client -result = await session.send_ping() - -# From a server (via ServerSession) -result = await server_session.send_ping() -``` - -Both return an `EmptyResult` on success. If the remote side does not respond within the session timeout, an exception is raised. - -## Cancellation - -Either side can cancel a previously-issued request by sending a `CancelledNotification`: - - -```python -import mcp.types as types -from mcp import ClientSession - - -async def cancel_request(session: ClientSession) -> None: - """Send a cancellation notification for a previously-issued request.""" - await session.send_notification( - types.ClientNotification( - types.CancelledNotification( - params=types.CancelledNotificationParams( - requestId="request-id-to-cancel", - reason="User navigated away", - ) - ) - ) - ) -``` - -_Full example: [examples/snippets/clients/cancellation.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/cancellation.py)_ - - -The `CancelledNotificationParams` fields: - -- `requestId` (optional): The ID of the request to cancel. Required for non-task cancellations. -- `reason` (optional): A human-readable string describing why the request was cancelled. - -## Capability Negotiation - -During initialization, the client and server exchange capability declarations. The Python SDK automatically declares capabilities based on which callbacks and handlers are registered: - -**Client capabilities** (auto-declared when callbacks are provided): - -- `sampling` -- declared when `sampling_callback` is passed to `ClientSession` -- `roots` -- declared when `list_roots_callback` is passed to `ClientSession` -- `elicitation` -- declared when `elicitation_callback` is passed to `ClientSession` - -**Server capabilities** (auto-declared when handlers are registered): - -- `prompts` -- declared when a `list_prompts` handler is registered -- `resources` -- declared when a `list_resources` handler is registered -- `tools` -- declared when a `list_tools` handler is registered -- `logging` -- declared when a `set_logging_level` handler is registered -- `completions` -- declared when a `completion` handler is registered - -After initialization, clients can inspect server capabilities: - -```python -capabilities = session.get_server_capabilities() -if capabilities and capabilities.tools: - tools = await session.list_tools() -``` - -## Protocol Version Negotiation - -The SDK defines `LATEST_PROTOCOL_VERSION` and `SUPPORTED_PROTOCOL_VERSIONS` in `mcp.shared.version`: - -```python -from mcp.shared.version import LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS - -# LATEST_PROTOCOL_VERSION is the version the SDK advertises during initialization -# SUPPORTED_PROTOCOL_VERSIONS lists all versions the SDK can work with -``` - -During initialization, the client sends `LATEST_PROTOCOL_VERSION`. If the server responds with a version not in `SUPPORTED_PROTOCOL_VERSIONS`, the client raises a `RuntimeError`. This ensures both sides agree on a compatible protocol version before exchanging messages. - -## JSON Schema (2020-12) - -MCP uses [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) for tool input schemas, output schemas, and elicitation schemas. When using Pydantic models, schemas are generated automatically via `model_json_schema()`: - - -```python -from pydantic import BaseModel, Field - - -class SearchParams(BaseModel): - query: str = Field(description="Search query string") - max_results: int = Field(default=10, description="Maximum results to return") - - -# Pydantic generates a JSON Schema 2020-12 compatible schema: -schema = SearchParams.model_json_schema() -# { -# "properties": { -# "query": {"description": "Search query string", "type": "string"}, -# "max_results": { -# "default": 10, -# "description": "Maximum results to return", -# "type": "integer", -# }, -# }, -# "required": ["query"], -# "title": "SearchParams", -# "type": "object", -# } -``` - -_Full example: [examples/snippets/servers/json_schema_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/json_schema_example.py)_ - - -For FastMCP tools, input schemas are derived automatically from function signatures. For structured output, the output schema is derived from the return type annotation. - -## Pagination - -For pagination details, see: - -- Server-side implementation: [Low-Level Server - Pagination](low-level-server.md#pagination-advanced) -- Client-side consumption: [Low-Level Server - Client-side Consumption](low-level-server.md#client-side-consumption) diff --git a/src/agent_workflow/mcp/sdk/docs/server.md b/src/agent_workflow/mcp/sdk/docs/server.md deleted file mode 100644 index 6340687..0000000 --- a/src/agent_workflow/mcp/sdk/docs/server.md +++ /dev/null @@ -1,1831 +0,0 @@ -# Building MCP Servers - -## Core Concepts - -### Server - -The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - - -```python -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = FastMCP("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[ServerSession, AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() -``` - -_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lifespan_example.py)_ - - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" -``` - -_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/basic_resource.py)_ - - -#### Resource Templates and Template Reading - -Resources with URI parameters (e.g., `{name}`) are registered as templates. When a client reads a templated resource, the URI parameters are extracted and passed to the function: - - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Template Example") - - -@mcp.resource("users://{user_id}/profile") -def get_user_profile(user_id: str) -> str: - """Read a specific user's profile. The user_id is extracted from the URI.""" - return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' -``` - -_Full example: [examples/snippets/servers/resource_templates.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/resource_templates.py)_ - - -Clients read a template resource by providing a concrete URI: - -```python -# Client-side: read a template resource with a concrete URI -content = await session.read_resource("users://alice/profile") -``` - -Templates with multiple parameters work the same way: - -```python -@mcp.resource("repos://{owner}/{repo}/readme") -def get_readme(owner: str, repo: str) -> str: - """Each URI parameter becomes a function argument.""" - return f"README for {owner}/{repo}" -``` - -#### Binary Resources - -Resources can return binary data by returning `bytes` instead of `str`. Set the `mime_type` to indicate the content type: - - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Binary Resource Example") - - -@mcp.resource("images://logo.png", mime_type="image/png") -def get_logo() -> bytes: - """Return a binary image resource.""" - with open("logo.png", "rb") as f: - return f.read() -``` - -_Full example: [examples/snippets/servers/binary_resources.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/binary_resources.py)_ - - -Binary content is automatically base64-encoded and returned as `BlobResourceContents` in the MCP response. - -#### Resource Subscriptions - -Clients can subscribe to resource updates. Use the low-level server API to handle subscription and unsubscription requests: - - -```python -from mcp.server.lowlevel import Server - -server = Server("Subscription Example") - -subscriptions: dict[str, set[str]] = {} # uri -> set of session ids - - -@server.subscribe_resource() -async def handle_subscribe(uri) -> None: - """Handle a client subscribing to a resource.""" - subscriptions.setdefault(str(uri), set()).add("current_session") - - -@server.unsubscribe_resource() -async def handle_unsubscribe(uri) -> None: - """Handle a client unsubscribing from a resource.""" - if str(uri) in subscriptions: - subscriptions[str(uri)].discard("current_session") -``` - -_Full example: [examples/snippets/servers/resource_subscriptions.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/resource_subscriptions.py)_ - - -When a subscribed resource changes, notify clients with `send_resource_updated()`: - -```python -from pydantic import AnyUrl - -# After modifying resource data: -await session.send_resource_updated(AnyUrl("resource://my-resource")) -``` - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" -``` - -_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/basic_tool.py)_ - - -#### Error Handling - -When a tool encounters an error, it should signal this to the client rather than returning a normal result. The MCP protocol uses the `isError` flag on `CallToolResult` to distinguish error responses from successful ones. There are three ways to handle errors: - - -```python -"""Example showing how to handle and return errors from tools.""" - -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.exceptions import ToolError -from mcp.types import CallToolResult, TextContent - -mcp = FastMCP("Tool Error Handling Example") - - -# Option 1: Raise ToolError for expected error conditions. -# The error message is returned to the client with isError=True. -@mcp.tool() -def divide(a: float, b: float) -> float: - """Divide two numbers.""" - if b == 0: - raise ToolError("Cannot divide by zero") - return a / b - - -# Option 2: Unhandled exceptions are automatically caught and -# converted to error responses with isError=True. -@mcp.tool() -def read_config(path: str) -> str: - """Read a configuration file.""" - # If this raises FileNotFoundError, the client receives an - # error response like "Error executing tool read_config: ..." - with open(path) as f: - return f.read() - - -# Option 3: Return CallToolResult directly for full control -# over error responses, including custom content. -@mcp.tool() -def validate_input(data: str) -> CallToolResult: - """Validate input data.""" - errors: list[str] = [] - if len(data) < 3: - errors.append("Input must be at least 3 characters") - if not data.isascii(): - errors.append("Input must be ASCII only") - - if errors: - return CallToolResult( - content=[TextContent(type="text", text="\n".join(errors))], - isError=True, - ) - return CallToolResult( - content=[TextContent(type="text", text="Validation passed")], - ) -``` - -_Full example: [examples/snippets/servers/tool_errors.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_errors.py)_ - - -- **`ToolError`** is the preferred approach for most cases — raise it with a descriptive message and the framework handles the rest. -- **Unhandled exceptions** are caught automatically, so tools won't crash the server. The exception message is forwarded to the client as an error response. -- **`CallToolResult`** with `isError=True` gives full control when you need to customize the error content or include multiple content items. - -Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the FastMCP framework and provides access to MCP capabilities: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_progress.py)_ - - -#### Structured Output - -Tools will return structured results by default, if their return type -annotation is compatible. Otherwise, they will return unstructured results. - -Structured output supports these return types: - -- Pydantic models (BaseModel subclasses) -- TypedDicts -- Dataclasses and other classes with type hints -- `dict[str, T]` (where T is any JSON-serializable type) -- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` -- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` - -Classes without type hints cannot be serialized for structured output. Only -classes with properly annotated attributes will be converted to Pydantic models -for schema generation and validation. - -Structured results are automatically validated against the output schema -generated from the annotation. This ensures the tool returns well-typed, -validated data that clients can easily process. - -**Note:** For backward compatibility, unstructured results are also -returned. Unstructured results are provided for backward compatibility -with previous versions of the MCP specification, and are quirks-compatible -with previous versions of FastMCP in the current version of the SDK. - -**Note:** In cases where a tool function's return type annotation -causes the tool to be classified as structured _and this is undesirable_, -the classification can be suppressed by passing `structured_output=False` -to the `@tool` decorator. - -##### Advanced: Direct CallToolResult - -For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: - - -```python -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from pydantic import BaseModel - -from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, TextContent - -mcp = FastMCP("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structuredContent={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) -``` - -_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/direct_call_tool_result.py)_ - - -**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. - - -```python -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} -``` - -_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/structured_output.py)_ - - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - - -```python -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/basic_prompt.py)_ - - -#### Prompts with Embedded Resources - -Prompts can include embedded resources to provide file contents or data alongside the conversation messages: - - -```python -import mcp.types as types -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP("Embedded Resource Prompt Example") - - -@mcp.prompt() -def review_file(filename: str) -> list[base.Message]: - """Review a file with its contents embedded.""" - file_content = open(filename).read() - return [ - base.UserMessage( - content=types.TextContent(type="text", text=f"Please review {filename}:"), - ), - base.UserMessage( - content=types.EmbeddedResource( - type="resource", - resource=types.TextResourceContents( - uri=f"file://{filename}", - text=file_content, - mimeType="text/plain", - ), - ), - ), - ] -``` - -_Full example: [examples/snippets/servers/prompt_embedded_resources.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/prompt_embedded_resources.py)_ - - -#### Prompts with Image Content - -Prompts can include images using `ImageContent` or the `Image` helper class: - - -```python -import mcp.types as types -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base -from mcp.server.fastmcp.utilities.types import Image - -mcp = FastMCP("Image Prompt Example") - - -@mcp.prompt() -def describe_image(image_path: str) -> list[base.Message]: - """Prompt that includes an image for analysis.""" - img = Image(path=image_path) - return [ - base.UserMessage( - content=types.TextContent(type="text", text="Describe this image:"), - ), - base.UserMessage( - content=img.to_image_content(), - ), - ] -``` - -_Full example: [examples/snippets/servers/prompt_image_content.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/prompt_image_content.py)_ - - -#### Prompt Change Notifications - -When your server dynamically adds or removes prompts, notify connected clients: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Dynamic Prompts") - - -@mcp.tool() -async def update_prompts(ctx: Context[ServerSession, None]) -> str: - """Update available prompts and notify clients.""" - # ... modify prompts ... - await ctx.session.send_prompt_list_changed() - return "Prompts updated" -``` - -_Full example: [examples/snippets/servers/prompt_change_notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/prompt_change_notifications.py)_ - - -### Icons - -MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: - -```python -from mcp.server.fastmcp import FastMCP, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mimeType="image/png", - sizes=["64x64"] -) - -# Add icons to server -mcp = FastMCP( - "My Server", - website_url="https://example.com", - icons=[icon] -) - -# Add icons to tools, resources, and prompts -@mcp.tool(icons=[icon]) -def my_tool(): - """Tool with an icon.""" - return "result" - -@mcp.resource("demo://resource", icons=[icon]) -def my_resource(): - """Resource with an icon.""" - return "content" -``` - -_Full example: [examples/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/fastmcp/icons_demo.py)_ - -### Images - -FastMCP provides an `Image` class that automatically handles image data: - - -```python -"""Example showing image handling with FastMCP.""" - -from PIL import Image as PILImage - -from mcp.server.fastmcp import FastMCP, Image - -mcp = FastMCP("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/images.py)_ - - -### Audio - -FastMCP provides an `Audio` class for returning audio data from tools, similar to `Image`: - - -```python -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.utilities.types import Audio - -mcp = FastMCP("Audio Example") - - -@mcp.tool() -def get_audio_from_file(file_path: str) -> Audio: - """Return audio from a file path (format auto-detected from extension).""" - return Audio(path=file_path) - - -@mcp.tool() -def get_audio_from_bytes(raw_audio: bytes) -> Audio: - """Return audio from raw bytes with explicit format.""" - return Audio(data=raw_audio, format="wav") -``` - -_Full example: [examples/snippets/servers/audio_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/audio_example.py)_ - - -The `Audio` class accepts `path` or `data` (mutually exclusive) and an optional `format` string. Supported formats include `wav`, `mp3`, `ogg`, `flac`, `aac`, and `m4a`. When using a file path, the MIME type is inferred from the file extension. - -### Embedded Resource Results - -Tools can return `EmbeddedResource` to attach file contents or data inline in the result: - - -```python -from mcp.server.fastmcp import FastMCP -from mcp.types import EmbeddedResource, TextResourceContents - -mcp = FastMCP("Embedded Resource Example") - - -@mcp.tool() -def read_config(path: str) -> EmbeddedResource: - """Read a config file and return it as an embedded resource.""" - with open(path) as f: - content = f.read() - return EmbeddedResource( - type="resource", - resource=TextResourceContents( - uri=f"file://{path}", - text=content, - mimeType="application/json", - ), - ) -``` - -_Full example: [examples/snippets/servers/embedded_resource_results.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/embedded_resource_results.py)_ - - -For binary embedded resources, use `BlobResourceContents` with base64-encoded data: - - -```python -import base64 - -from mcp.server.fastmcp import FastMCP -from mcp.types import BlobResourceContents, EmbeddedResource - -mcp = FastMCP("Binary Embedded Resource Example") - - -@mcp.tool() -def read_binary_file(path: str) -> EmbeddedResource: - """Read a binary file and return it as an embedded resource.""" - with open(path, "rb") as f: - data = base64.b64encode(f.read()).decode() - return EmbeddedResource( - type="resource", - resource=BlobResourceContents( - uri=f"file://{path}", - blob=data, - mimeType="application/octet-stream", - ), - ) -``` - -_Full example: [examples/snippets/servers/embedded_resource_results_binary.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/embedded_resource_results_binary.py)_ - - -### Tool Change Notifications - -When your server dynamically adds or removes tools at runtime, notify connected clients so they can refresh their tool list: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Dynamic Tools") - - -@mcp.tool() -async def register_plugin(name: str, ctx: Context[ServerSession, None]) -> str: - """Dynamically register a new tool and notify the client.""" - # ... register the plugin's tools ... - - # Notify the client that the tool list has changed - await ctx.session.send_tool_list_changed() - - return f"Plugin '{name}' registered" -``` - -_Full example: [examples/snippets/servers/tool_change_notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_change_notifications.py)_ - - -### Context - -The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. - -#### Getting Context in Functions - -To use context in a tool or resource function, add a parameter with the `Context` type annotation: - -```python -from mcp.server.fastmcp import Context, FastMCP - -mcp = FastMCP(name="Context Example") - - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - """Tool that uses context capabilities.""" - # The context parameter can have any name as long as it's type-annotated - return await process_with_context(x, ctx) -``` - -#### Context Properties and Methods - -The Context object provides the following capabilities: - -- `ctx.request_id` - Unique ID for the current request -- `ctx.client_id` - Client ID if available -- `ctx.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-properties)) -- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) -- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(message)` - Send debug log message -- `await ctx.info(message)` - Send info log message -- `await ctx.warning(message)` - Send warning log message -- `await ctx.error(message)` - Send error log message -- `await ctx.log(level, message, logger_name=None)` - Send log with custom level -- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress -- `await ctx.read_resource(uri)` - Read a resource by URI -- `await ctx.elicit(message, schema)` - Request additional information from user with validation - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_progress.py)_ - - -### Completions - -MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: - -Client usage: - - -```python -""" -cd to the `examples/snippets` directory and run: - uv run completion-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.types import PromptReference, ResourceTemplateReference - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resourceTemplates: - print(f" - {template.uriTemplate}") - - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") - - # Complete resource template arguments - if templates.resourceTemplates: - template = templates.resourceTemplates[0] - print(f"\nCompleting arguments for resource template: {template.uriTemplate}") - - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") - - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") - - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") - - -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/completion_client.py)_ - -### Elicitation - -Request additional information from users. This example shows an Elicitation during a Tool Call: - - -```python -"""Elicitation examples demonstrating form and URL mode elicitation. - -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" - -import uuid - -from pydantic import BaseModel, Field - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import ElicitRequestURLParams - -mcp = FastMCP(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitationId=elicitation_id, - ) - ] - ) -``` - -_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/elicitation.py)_ - - -Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. - -The `elicit()` method returns an `ElicitationResult` with: - -- `action`: "accept", "decline", or "cancel" -- `data`: The validated response (only when accepted) - -#### Elicitation with Enum Values - -To present a dropdown or selection list in elicitation forms, use `json_schema_extra` with an `enum` key on a `str` field. Do not use `Literal` -- use a plain `str` field with the enum constraint in the JSON schema: - - -```python -from pydantic import BaseModel, Field - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Enum Elicitation Example") - - -class ColorPreference(BaseModel): - color: str = Field( - description="Pick your favorite color", - json_schema_extra={"enum": ["red", "green", "blue", "yellow"]}, - ) - - -@mcp.tool() -async def pick_color(ctx: Context[ServerSession, None]) -> str: - """Ask the user to pick a color from a list.""" - result = await ctx.elicit( - message="Choose a color:", - schema=ColorPreference, - ) - if result.action == "accept": - return f"You picked: {result.data.color}" - return "No color selected" -``` - -_Full example: [examples/snippets/servers/elicitation_enum.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/elicitation_enum.py)_ - - -#### Elicitation Complete Notification - -For URL mode elicitations, send a completion notification after the out-of-band interaction finishes. This tells the client that the elicitation is done and it may retry any blocked requests: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Elicit Complete Example") - - -@mcp.tool() -async def handle_oauth_callback(elicitation_id: str, ctx: Context[ServerSession, None]) -> str: - """Called when OAuth flow completes out-of-band.""" - # ... process the callback ... - - # Notify the client that the elicitation is done - await ctx.session.send_elicit_complete(elicitation_id) - - return "Authorization complete" -``` - -_Full example: [examples/snippets/servers/elicitation_complete.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/elicitation_complete.py)_ - - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.types import SamplingMessage, TextContent - -mcp = FastMCP(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) -``` - -_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/sampling.py)_ - - -### Logging and Notifications - -Tools can send logs and notifications through the context: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") - await ctx.info("Info: Starting processing") - await ctx.warning("Warning: This is experimental") - await ctx.error("Error: (This is just a demo)") - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" -``` - -_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/notifications.py)_ - - -#### Setting the Logging Level - -Clients can request a minimum logging level via `logging/setLevel`. Use the low-level server API to handle this: - - -```python -import mcp.types as types -from mcp.server.lowlevel import Server - -server = Server("Logging Level Example") - -current_level: types.LoggingLevel = "warning" - - -@server.set_logging_level() -async def handle_set_level(level: types.LoggingLevel) -> None: - """Handle client request to change the logging level.""" - global current_level - current_level = level -``` - -_Full example: [examples/snippets/servers/set_logging_level.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/set_logging_level.py)_ - - -When this handler is registered, the server automatically declares the `logging` capability during initialization. - -### Authentication - -For OAuth 2.1 server and client authentication, see [Authorization](authorization.md). - -### FastMCP Properties - -The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: - -- `ctx.fastmcp.name` - The server's name as defined during initialization -- `ctx.fastmcp.instructions` - Server instructions/description provided to clients -- `ctx.fastmcp.website_url` - Optional website URL for the server -- `ctx.fastmcp.icons` - Optional list of icons for UI display -- `ctx.fastmcp.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `mount_path`, `sse_path`, `streamable_http_path` - Transport paths - - `stateless_http` - Whether the server operates in stateless mode - - And other configuration options - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.fastmcp.name, - "instructions": ctx.fastmcp.instructions, - "debug_mode": ctx.fastmcp.settings.debug, - "log_level": ctx.fastmcp.settings.log_level, - "host": ctx.fastmcp.settings.host, - "port": ctx.fastmcp.settings.port, - } -``` - -### Session Properties and Methods - -The session object accessible via `ctx.session` provides advanced control over client communication: - -- `ctx.session.client_params` - Client initialization parameters and declared capabilities -- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control -- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion -- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates -- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed -- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed -- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed -- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed - -```python -@mcp.tool() -async def notify_data_update(resource_uri: str, ctx: Context) -> str: - """Update data and notify clients of the change.""" - # Perform data update logic here - - # Notify clients that this specific resource changed - await ctx.session.send_resource_updated(AnyUrl(resource_uri)) - - # If this affects the overall resource list, notify about that too - await ctx.session.send_resource_list_changed() - - return f"Updated {resource_uri} and notified clients" -``` - -### Request Context Properties - -The request context accessible via `ctx.request_context` contains request-specific information and resources: - -- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup - - Database connections, configuration objects, shared services - - Type-safe access to resources defined in your server's lifespan function -- `ctx.request_context.meta` - Request metadata from the client including: - - `progressToken` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - The original MCP request object for advanced processing -- `ctx.request_context.request_id` - Unique identifier for this request - -```python -# Example with typed lifespan context -@dataclass -class AppContext: - db: Database - config: AppConfig - -@mcp.tool() -def query_with_config(query: str, ctx: Context) -> str: - """Execute a query using shared database and configuration.""" - # Access typed lifespan context - app_ctx: AppContext = ctx.request_context.lifespan_context - - # Use shared resources - connection = app_ctx.db - settings = app_ctx.config - - # Execute query with configuration - result = connection.execute(query, timeout=settings.query_timeout) - return str(result) -``` - -_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lifespan_example.py)_ - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -uv run mcp dev server.py - -# Add dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code -uv run mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -uv run mcp install server.py - -# Custom name -uv run mcp install server.py --name "My Analytics Server" - -# Environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -uv run mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - - -```python -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/direct_execution.py)_ - - -Run it with: - -```bash -python servers/direct_execution.py -# or -uv run mcp run servers/direct_execution.py -``` - -Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMCP and not the low-level server variant. - -### Streamable HTTP Transport - -> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.fastmcp import FastMCP - -# Stateless server with JSON responses (recommended) -mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) - -# Other configuration options: -# Stateless server with SSE streaming responses -# mcp = FastMCP("StatelessServer", stateless_http=True) - -# Stateful server with session persistence -# mcp = FastMCP("StatefulServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_config.py)_ - - -You can mount multiple FastMCP servers in a Starlette application: - - -```python -""" -Run from the repository root: - uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create the Echo server -echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app()), - Mount("/math", math_mcp.streamable_http_app()), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.settings.streamable_http_path = "/" -# math_mcp.settings.streamable_http_path = "/" -``` - -_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_starlette_mount.py)_ - - -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/servers/simple-streamablehttp) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x/examples/servers/simple-streamablehttp-stateless) - -The streamable HTTP transport supports: - -- Stateful and stateless operation modes -- Resumability with event stores -- JSON or SSE response formats -- Better scalability for multi-node deployments - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: - -```python -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware - -# Create your Starlette app first -starlette_app = Starlette(routes=[...]) - -# Then wrap it with CORS middleware -starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Configure appropriately for production - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], -) -``` - -This configuration is necessary because: - -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses - -### Mounting to an Existing ASGI Server - -By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -#### StreamableHTTP servers - -You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. - -##### Basic mounting - - -```python -""" -Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("My App", json_response=True) - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_basic_mounting.py)_ - - -##### Host-based routing - - -```python -""" -Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("MCP Host App", json_response=True) - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_host_mounting.py)_ - - -##### Multiple servers with path configuration - - -```python -""" -Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -api_mcp = FastMCP("API Server", json_response=True) -chat_mcp = FastMCP("Chat Server", json_response=True) - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Configure servers to mount at the root of each path -# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -api_mcp.settings.streamable_http_path = "/" -chat_mcp.settings.streamable_http_path = "/" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app()), - Mount("/chat", app=chat_mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_multiple_servers.py)_ - - -##### Path configuration at initialization - - -```python -""" -Example showing path configuration during FastMCP initialization. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Configure streamable_http_path during initialization -# This server will mount at the root of wherever it's mounted -mcp_at_root = FastMCP( - "My Server", - json_response=True, - streamable_http_path="/", -) - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process - endpoints will be at /process instead of /process/mcp -app = Starlette( - routes=[ - Mount("/process", app=mcp_at_root.streamable_http_app()), - ] -) -``` - -_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_path_config.py)_ - - -#### SSE servers - -> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http). - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.fastmcp import FastMCP - - -mcp = FastMCP("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -github_mcp = FastMCP("GitHub API") -browser_mcp = FastMCP("Browser") -curl_mcp = FastMCP("Curl") -search_mcp = FastMCP("Search") - -# Method 1: Configure mount paths via settings (recommended for persistent configuration) -github_mcp.settings.mount_path = "/github" -browser_mcp.settings.mount_path = "/browser" - -# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) -# This approach doesn't modify the server's settings permanently - -# Create Starlette app with multiple mounted servers -app = Starlette( - routes=[ - # Using settings-based configuration - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - # Using direct mount path parameter - Mount("/curl", app=curl_mcp.sse_app("/curl")), - Mount("/search", app=search_mcp.sse_app("/search")), - ] -) - -# Method 3: For direct execution, you can also pass the mount path to run() -if __name__ == "__main__": - search_mcp.run(transport="sse", mount_path="/search") -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Advanced Usage - -For the low-level server API, pagination, and direct handler registration, see [Low-Level Server](low-level-server.md). diff --git a/src/agent_workflow/mcp/sdk/docs/testing.md b/src/agent_workflow/mcp/sdk/docs/testing.md deleted file mode 100644 index 8d84449..0000000 --- a/src/agent_workflow/mcp/sdk/docs/testing.md +++ /dev/null @@ -1,78 +0,0 @@ -# Testing MCP Servers - -If you call yourself a developer, you will want to test your MCP server. -The Python SDK offers the `create_connected_server_and_client_session` function to create a session -using an in-memory transport. I know, I know, the name is too long... We are working on improving it. - -Anyway, let's assume you have a simple server with a single tool: - -```python title="server.py" -from mcp.server import FastMCP - -app = FastMCP("Calculator") - -@app.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" # (1)! - return a + b -``` - -1. The docstring is automatically added as the description of the tool. - -To run the below test, you'll need to install the following dependencies: - -=== "pip" - ```bash - pip install inline-snapshot pytest - ``` - -=== "uv" - ```bash - uv add inline-snapshot pytest - ``` - -!!! info - I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework, - so I won't go into details here. - - The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows - you to take snapshots of the output of your tests. Which makes it easier to create tests for your - server - you don't need to use it, but we are spreading the word for best practices. - -```python title="test_server.py" -from collections.abc import AsyncGenerator - -import pytest -from inline_snapshot import snapshot -from mcp.client.session import ClientSession -from mcp.shared.memory import create_connected_server_and_client_session -from mcp.types import CallToolResult, TextContent - -from server import app - - -@pytest.fixture -def anyio_backend(): # (1)! - return "asyncio" - - -@pytest.fixture -async def client_session() -> AsyncGenerator[ClientSession]: - async with create_connected_server_and_client_session(app, raise_exceptions=True) as _session: - yield _session - - -@pytest.mark.anyio -async def test_call_add_tool(client_session: ClientSession): - result = await client_session.call_tool("add", {"a": 1, "b": 2}) - assert result == snapshot( - CallToolResult( - content=[TextContent(type="text", text="3")], - structuredContent={"result": 3}, - ) - ) -``` - -1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). - -There you go! You can now extend your tests to cover more scenarios. diff --git a/src/agent_workflow/mcp/sdk/mkdocs.yml b/src/agent_workflow/mcp/sdk/mkdocs.yml deleted file mode 100644 index 7245f23..0000000 --- a/src/agent_workflow/mcp/sdk/mkdocs.yml +++ /dev/null @@ -1,134 +0,0 @@ -site_name: MCP Server -site_description: MCP Server -strict: true - -repo_name: modelcontextprotocol/python-sdk -repo_url: https://github.com/modelcontextprotocol/python-sdk -edit_uri: edit/v1.x/docs/ -site_url: https://py.sdk.modelcontextprotocol.io/ - -# TODO(Marcelo): Add Anthropic copyright? -# copyright: © Model Context Protocol 2025 to present - -nav: - - Introduction: index.md - - Installation: installation.md - - Documentation: - - Building Servers: server.md - - Writing Clients: client.md - - Protocol Features: protocol.md - - Low-Level Server: low-level-server.md - - Authorization: authorization.md - - Testing: testing.md - - Experimental: - - Overview: experimental/index.md - - Tasks: - - Introduction: experimental/tasks.md - - Server Implementation: experimental/tasks-server.md - - Client Usage: experimental/tasks-client.md - - API Reference: api.md - -theme: - name: "material" - palette: - - media: "(prefers-color-scheme)" - scheme: default - primary: black - accent: black - toggle: - icon: material/lightbulb - name: "Switch to light mode" - - media: "(prefers-color-scheme: light)" - scheme: default - primary: black - accent: black - toggle: - icon: material/lightbulb-outline - name: "Switch to dark mode" - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: white - accent: white - toggle: - icon: material/lightbulb-auto-outline - name: "Switch to system preference" - features: - - search.suggest - - search.highlight - - content.tabs.link - - content.code.annotate - - content.code.copy - - content.code.select - - navigation.path - - navigation.indexes - - navigation.sections - - navigation.tracking - - toc.follow - # logo: "img/logo-white.svg" - # TODO(Marcelo): Add a favicon. - # favicon: "favicon.ico" - -# https://www.mkdocs.org/user-guide/configuration/#validation -validation: - omitted_files: warn - absolute_links: warn - unrecognized_links: warn - anchors: warn - -markdown_extensions: - - tables - - admonition - - attr_list - - md_in_html - - pymdownx.details - - pymdownx.caret - - pymdownx.critic - - pymdownx.mark - - pymdownx.superfences - - pymdownx.snippets - - pymdownx.tilde - - pymdownx.inlinehilite - - pymdownx.highlight: - pygments_lang_class: true - - pymdownx.extra: - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - options: - custom_icons: - - docs/.overrides/.icons - - pymdownx.tabbed: - alternate_style: true - - pymdownx.tasklist: - custom_checkbox: true - - sane_lists # this means you can start a list from any number - -watch: - - src/mcp - -plugins: - - search - - social - - glightbox - - mkdocstrings: - handlers: - python: - paths: [src/mcp] - options: - relative_crossrefs: true - members_order: source - separate_signature: true - show_signature_annotations: true - signature_crossrefs: true - group_by_category: false - # 3 because docs are in pages with an H2 just above them - heading_level: 3 - import: - - url: https://docs.python.org/3/objects.inv - - url: https://docs.pydantic.dev/latest/objects.inv - - url: https://typing-extensions.readthedocs.io/en/latest/objects.inv diff --git a/src/agent_workflow/mcp/sdk/pyproject.toml b/src/agent_workflow/mcp/sdk/pyproject.toml deleted file mode 100644 index 8611f5c..0000000 --- a/src/agent_workflow/mcp/sdk/pyproject.toml +++ /dev/null @@ -1,230 +0,0 @@ -[project] -name = "mcp" -dynamic = ["version"] -description = "Model Context Protocol SDK" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] -maintainers = [ - { name = "David Soria Parra", email = "davidsp@anthropic.com" }, - { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, -] -keywords = ["git", "mcp", "llm", "automation"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", -] -dependencies = [ - "anyio>=4.5", - "httpx>=0.27.1,<1.0.0", - "httpx-sse>=0.4", - "pydantic>=2.12.0,<3.0.0; python_version >= '3.14'", - "pydantic>=2.11.0,<3.0.0; python_version < '3.14'", - "starlette>=0.48.0; python_version >= '3.14'", - "starlette>=0.27; python_version < '3.14'", - "python-multipart>=0.0.9", - "sse-starlette>=1.6.1", - "pydantic-settings>=2.5.2", - "uvicorn>=0.31.1; sys_platform != 'emscripten'", - "jsonschema>=4.20.0", - "pywin32>=311; sys_platform == 'win32' and python_version >= '3.14'", - "pywin32>=310; sys_platform == 'win32' and python_version < '3.14'", - "pyjwt[crypto]>=2.10.1", - "typing-extensions>=4.9.0", - "typing-inspection>=0.4.1", -] - -[project.optional-dependencies] -rich = ["rich>=13.9.4"] -cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] -ws = ["websockets>=15.0.1"] - -[project.scripts] -mcp = "mcp.cli:app [cli]" - -[tool.uv] -default-groups = ["dev", "docs"] -required-version = ">=0.9.5" - -[dependency-groups] -dev = [ - "pyright>=1.1.400", - "pytest>=8.3.4", - "ruff>=0.8.5", - "trio>=0.26.2", - "pytest-flakefinder>=1.1.0", - "pytest-xdist>=3.6.1", - "pytest-examples>=0.0.14", - "pytest-pretty>=1.2.0", - "inline-snapshot>=0.23.0", - "dirty-equals>=0.9.0", - "coverage[toml]==7.10.7", -] -docs = [ - "mkdocs>=1.6.1", - "mkdocs-glightbox>=0.4.0", - "mkdocs-material[imaging]>=9.6.19", - "mkdocstrings-python>=1.12.2", -] - -[build-system] -requires = ["hatchling", "uv-dynamic-versioning"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "uv-dynamic-versioning" - -[tool.uv-dynamic-versioning] -vcs = "git" -style = "pep440" -bump = true - -[project.urls] -Homepage = "https://modelcontextprotocol.io" -Documentation = "https://py.sdk.modelcontextprotocol.io/" -Repository = "https://github.com/modelcontextprotocol/python-sdk" -Issues = "https://github.com/modelcontextprotocol/python-sdk/issues" - -[tool.hatch.build.targets.wheel] -packages = ["src/mcp"] - -[tool.pyright] -typeCheckingMode = "strict" -include = ["src/mcp", "tests", "examples/servers", "examples/snippets"] -venvPath = "." -venv = ".venv" -# The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. -# See https://github.com/microsoft/pyright/issues/7771 for more details. -# TODO(Marcelo): We should remove `reportPrivateUsage = false`. The idea is that we should test the workflow that uses -# those private functions instead of testing the private functions directly. It makes it easier to maintain the code source -# and refactor code that is not public. -executionEnvironments = [ - # The experimental tasks API and the WebSocket transport are deprecated; the suites and - # examples that cover them intentionally use the deprecated APIs. - { root = "tests/experimental", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false, reportDeprecated = false }, - { root = "tests/shared/test_ws.py", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false, reportDeprecated = false }, - { root = "tests", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false }, - { root = "examples/servers/simple-task", reportUnusedFunction = false, reportDeprecated = false }, - { root = "examples/servers/simple-task-interactive", reportUnusedFunction = false, reportDeprecated = false }, - { root = "examples/servers", reportUnusedFunction = false }, - { root = "examples/snippets/clients/logging_client.py", reportUndefinedVariable = false, reportUnknownArgumentType = false }, - { root = "examples/snippets/clients/roots_example.py", reportUndefinedVariable = false, reportUnknownArgumentType = false, reportArgumentType = false }, - { root = "examples/snippets/servers/embedded_resource_results.py", reportArgumentType = false }, - { root = "examples/snippets/servers/embedded_resource_results_binary.py", reportArgumentType = false }, - { root = "examples/snippets/servers/prompt_embedded_resources.py", reportArgumentType = false }, - { root = "examples/snippets/servers/resource_subscriptions.py", reportUnknownParameterType = false, reportMissingParameterType = false, reportUnknownArgumentType = false }, -] - -[tool.ruff] -line-length = 120 -target-version = "py310" -extend-exclude = ["README.md"] - -[tool.ruff.lint] -select = [ - "C4", # flake8-comprehensions - "C90", # mccabe - "E", # pycodestyle - "F", # pyflakes - "I", # isort - "PERF", # Perflint - "PL", # Pylint - "UP", # pyupgrade -] -ignore = ["PERF203", "PLC0415", "PLR0402"] -mccabe.max-complexity = 24 # Default is 10 - -[tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401"] -"tests/server/fastmcp/test_func_metadata.py" = ["E501"] -"tests/shared/test_progress_notifications.py" = ["PLW0603"] -"examples/snippets/clients/logging_client.py" = ["F821"] -"examples/snippets/clients/roots_example.py" = ["F821"] -"examples/snippets/servers/set_logging_level.py" = ["PLW0603"] - -[tool.ruff.lint.pylint] -allow-magic-value-types = ["bytes", "float", "int", "str"] -max-args = 23 # Default is 5 -max-branches = 23 # Default is 12 -max-returns = 13 # Default is 6 -max-statements = 102 # Default is 50 - -[tool.uv.workspace] -members = ["examples/clients/*", "examples/servers/*", "examples/snippets"] - -[tool.uv.sources] -mcp = { workspace = true } - -[tool.pytest.ini_options] -log_cli = true -xfail_strict = true -addopts = """ - --color=yes - --capture=fd - --numprocesses auto -""" -filterwarnings = [ - "error", - # This should be fixed on Uvicorn's side. - "ignore::DeprecationWarning:websockets", - "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", - "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel", - # pywin32 internal deprecation warning - "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", -] - -[tool.markdown.lint] -default = true -MD004 = false # ul-style - Unordered list style -MD007.indent = 2 # ul-indent - Unordered list indentation -MD013 = false # line-length - Line length -MD029 = false # ol-prefix - Ordered list item prefix -MD033 = false # no-inline-html Inline HTML -MD041 = false # first-line-heading/first-line-h1 -MD046 = false # indented-code-blocks -MD059 = false # descriptive-link-text - -# https://coverage.readthedocs.io/en/latest/config.html#run -[tool.coverage.run] -branch = true -patch = ["subprocess"] -concurrency = ["multiprocessing", "thread"] -source = ["src", "tests"] -relative_files = true -omit = [ - "src/mcp/client/__main__.py", - "src/mcp/server/__main__.py", - "src/mcp/os/posix/utilities.py", - "src/mcp/os/win32/utilities.py", -] - -# https://coverage.readthedocs.io/en/latest/config.html#report -[tool.coverage.report] -fail_under = 100 -skip_covered = true -show_missing = true -ignore_errors = true -precision = 2 -exclude_lines = [ - "pragma: no cover", - "if TYPE_CHECKING:", - "@overload", - "raise NotImplementedError", - "^\\s*\\.\\.\\.\\s*$", -] - -# https://coverage.readthedocs.io/en/latest/config.html#paths -[tool.coverage.paths] -source = [ - "src/", - "/home/runner/work/python-sdk/python-sdk/src/", - 'D:\a\python-sdk\python-sdk\src', -] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/__init__.py deleted file mode 100644 index fbec40d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/__init__.py +++ /dev/null @@ -1,135 +0,0 @@ -from .client.session import ClientSession -from .client.session_group import ClientSessionGroup -from .client.stdio import StdioServerParameters, stdio_client -from .server.session import ServerSession -from .server.stdio import stdio_server -from .shared.exceptions import McpError, UrlElicitationRequiredError -from .types import ( - CallToolRequest, - ClientCapabilities, - ClientNotification, - ClientRequest, - ClientResult, - CompleteRequest, - CreateMessageRequest, - CreateMessageResult, - CreateMessageResultWithTools, - ErrorData, - GetPromptRequest, - GetPromptResult, - Implementation, - IncludeContext, - InitializedNotification, - InitializeRequest, - InitializeResult, - JSONRPCError, - JSONRPCRequest, - JSONRPCResponse, - ListPromptsRequest, - ListPromptsResult, - ListResourcesRequest, - ListResourcesResult, - ListToolsResult, - LoggingLevel, - LoggingMessageNotification, - Notification, - PingRequest, - ProgressNotification, - PromptsCapability, - ReadResourceRequest, - ReadResourceResult, - Resource, - ResourcesCapability, - ResourceUpdatedNotification, - RootsCapability, - SamplingCapability, - SamplingContent, - SamplingContextCapability, - SamplingMessage, - SamplingMessageContentBlock, - SamplingToolsCapability, - ServerCapabilities, - ServerNotification, - ServerRequest, - ServerResult, - SetLevelRequest, - StopReason, - SubscribeRequest, - Tool, - ToolChoice, - ToolResultContent, - ToolsCapability, - ToolUseContent, - UnsubscribeRequest, -) -from .types import ( - Role as SamplingRole, -) - -__all__ = [ - "CallToolRequest", - "ClientCapabilities", - "ClientNotification", - "ClientRequest", - "ClientResult", - "ClientSession", - "ClientSessionGroup", - "CompleteRequest", - "CreateMessageRequest", - "CreateMessageResult", - "CreateMessageResultWithTools", - "ErrorData", - "GetPromptRequest", - "GetPromptResult", - "Implementation", - "IncludeContext", - "InitializeRequest", - "InitializeResult", - "InitializedNotification", - "JSONRPCError", - "JSONRPCRequest", - "JSONRPCResponse", - "ListPromptsRequest", - "ListPromptsResult", - "ListResourcesRequest", - "ListResourcesResult", - "ListToolsResult", - "LoggingLevel", - "LoggingMessageNotification", - "McpError", - "Notification", - "PingRequest", - "ProgressNotification", - "PromptsCapability", - "ReadResourceRequest", - "ReadResourceResult", - "Resource", - "ResourcesCapability", - "ResourceUpdatedNotification", - "RootsCapability", - "SamplingCapability", - "SamplingContent", - "SamplingContextCapability", - "SamplingMessage", - "SamplingMessageContentBlock", - "SamplingRole", - "SamplingToolsCapability", - "ServerCapabilities", - "ServerNotification", - "ServerRequest", - "ServerResult", - "ServerSession", - "SetLevelRequest", - "StdioServerParameters", - "StopReason", - "SubscribeRequest", - "Tool", - "ToolChoice", - "ToolResultContent", - "ToolsCapability", - "ToolUseContent", - "UnsubscribeRequest", - "UrlElicitationRequiredError", - "stdio_client", - "stdio_server", -] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/cli/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/cli/__init__.py deleted file mode 100644 index b29bce8..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/cli/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""FastMCP CLI package.""" - -from .cli import app - -if __name__ == "__main__": # pragma: no cover - app() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/cli/claude.py b/src/agent_workflow/mcp/sdk/src/mcp/cli/claude.py deleted file mode 100644 index f2dc688..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/cli/claude.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Claude app integration utilities.""" - -import json -import os -import shutil -import sys -from pathlib import Path -from typing import Any - -from mcp.server.fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -MCP_PACKAGE = "mcp[cli]" - - -def get_claude_config_path() -> Path | None: # pragma: no cover - """Get the Claude config directory based on platform.""" - if sys.platform == "win32": - path = Path(Path.home(), "AppData", "Roaming", "Claude") - elif sys.platform == "darwin": - path = Path(Path.home(), "Library", "Application Support", "Claude") - elif sys.platform.startswith("linux"): - path = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude") - else: - return None - - if path.exists(): - return path - return None - - -def get_uv_path() -> str: - """Get the full path to the uv executable.""" - uv_path = shutil.which("uv") - if not uv_path: # pragma: no cover - logger.error( - "uv executable not found in PATH, falling back to 'uv'. Please ensure uv is installed and in your PATH" - ) - return "uv" # Fall back to just "uv" if not found - return uv_path - - -def update_claude_config( - file_spec: str, - server_name: str, - *, - with_editable: Path | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, -) -> bool: - """Add or update a FastMCP server in Claude's configuration. - - Args: - file_spec: Path to the server file, optionally with :object suffix - server_name: Name for the server in Claude's config - with_editable: Optional directory to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables. These are merged with - any existing variables, with new values taking precedence. - - Raises: - RuntimeError: If Claude Desktop's config directory is not found, indicating - Claude Desktop may not be installed or properly set up. - """ - config_dir = get_claude_config_path() - uv_path = get_uv_path() - if not config_dir: # pragma: no cover - raise RuntimeError( - "Claude Desktop config directory not found. Please ensure Claude Desktop" - " is installed and has been run at least once to initialize its config." - ) - - config_file = config_dir / "claude_desktop_config.json" - if not config_file.exists(): # pragma: no cover - try: - config_file.write_text("{}") - except Exception: - logger.exception( - "Failed to create Claude config file", - extra={ - "config_file": str(config_file), - }, - ) - return False - - try: - config = json.loads(config_file.read_text()) - if "mcpServers" not in config: - config["mcpServers"] = {} - - # Always preserve existing env vars and merge with new ones - if server_name in config["mcpServers"] and "env" in config["mcpServers"][server_name]: # pragma: no cover - existing_env = config["mcpServers"][server_name]["env"] - if env_vars: - # New vars take precedence over existing ones - env_vars = {**existing_env, **env_vars} - else: - env_vars = existing_env - - # Build uv run command - args = ["run", "--frozen"] - - # Collect all packages in a set to deduplicate - packages = {MCP_PACKAGE} - if with_packages: # pragma: no cover - packages.update(pkg for pkg in with_packages if pkg) - - # Add all packages with --with - for pkg in sorted(packages): - args.extend(["--with", pkg]) - - if with_editable: # pragma: no cover - args.extend(["--with-editable", str(with_editable)]) - - # Convert file path to absolute before adding to command - # Split off any :object suffix first - if ":" in file_spec: - file_path, server_object = file_spec.rsplit(":", 1) - file_spec = f"{Path(file_path).resolve()}:{server_object}" - else: # pragma: no cover - file_spec = str(Path(file_spec).resolve()) - - # Add fastmcp run command - args.extend(["mcp", "run", file_spec]) - - server_config: dict[str, Any] = {"command": uv_path, "args": args} - - # Add environment variables if specified - if env_vars: # pragma: no cover - server_config["env"] = env_vars - - config["mcpServers"][server_name] = server_config - - config_file.write_text(json.dumps(config, indent=2)) - logger.info( - f"Added server '{server_name}' to Claude config", - extra={"config_file": str(config_file)}, - ) - return True - except Exception: # pragma: no cover - logger.exception( - "Failed to update Claude config", - extra={ - "config_file": str(config_file), - }, - ) - return False diff --git a/src/agent_workflow/mcp/sdk/src/mcp/cli/cli.py b/src/agent_workflow/mcp/sdk/src/mcp/cli/cli.py deleted file mode 100644 index c4cae0d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/cli/cli.py +++ /dev/null @@ -1,488 +0,0 @@ -"""MCP CLI tools.""" - -import importlib.metadata -import importlib.util -import os -import subprocess -import sys -from pathlib import Path -from typing import Annotated, Any - -from mcp.server import FastMCP -from mcp.server import Server as LowLevelServer - -try: - import typer -except ImportError: # pragma: no cover - print("Error: typer is required. Install with 'pip install mcp[cli]'") - sys.exit(1) - -try: - from mcp.cli import claude - from mcp.server.fastmcp.utilities.logging import get_logger -except ImportError: # pragma: no cover - print("Error: mcp.server.fastmcp is not installed or not in PYTHONPATH") - sys.exit(1) - -try: - import dotenv -except ImportError: # pragma: no cover - dotenv = None - -logger = get_logger("cli") - -app = typer.Typer( - name="mcp", - help="MCP development tools", - add_completion=False, - no_args_is_help=True, # Show help if no args provided -) - - -def _get_npx_command(): - """Get the correct npx command for the current platform.""" - if sys.platform == "win32": - # Try both npx.cmd and npx.exe on Windows - for cmd in ["npx.cmd", "npx.exe", "npx"]: - try: - subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True) - return cmd - except subprocess.CalledProcessError: - continue - return None - return "npx" # On Unix-like systems, just use npx - - -def _parse_env_var(env_var: str) -> tuple[str, str]: # pragma: no cover - """Parse environment variable string in format KEY=VALUE.""" - if "=" not in env_var: - logger.error(f"Invalid environment variable format: {env_var}. Must be KEY=VALUE") - sys.exit(1) - key, value = env_var.split("=", 1) - return key.strip(), value.strip() - - -def _build_uv_command( - file_spec: str, - with_editable: Path | None = None, - with_packages: list[str] | None = None, -) -> list[str]: - """Build the uv run command that runs an MCP server through mcp run.""" - cmd = ["uv"] - - cmd.extend(["run", "--with", "mcp"]) - - if with_editable: - cmd.extend(["--with-editable", str(with_editable)]) - - if with_packages: - for pkg in with_packages: - if pkg: # pragma: no cover - cmd.extend(["--with", pkg]) - - # Add mcp run command - cmd.extend(["mcp", "run", file_spec]) - return cmd - - -def _parse_file_path(file_spec: str) -> tuple[Path, str | None]: - """Parse a file path that may include a server object specification. - - Args: - file_spec: Path to file, optionally with :object suffix - - Returns: - Tuple of (file_path, server_object) - """ - # First check if we have a Windows path (e.g., C:\...) - has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":" - - # Split on the last colon, but only if it's not part of the Windows drive letter - # and there's actually another colon in the string after the drive letter - if ":" in (file_spec[2:] if has_windows_drive else file_spec): - file_str, server_object = file_spec.rsplit(":", 1) - else: - file_str, server_object = file_spec, None - - # Resolve the file path - file_path = Path(file_str).expanduser().resolve() - if not file_path.exists(): - logger.error(f"File not found: {file_path}") - sys.exit(1) - if not file_path.is_file(): - logger.error(f"Not a file: {file_path}") - sys.exit(1) - - return file_path, server_object - - -def _import_server(file: Path, server_object: str | None = None): # pragma: no cover - """Import an MCP server from a file. - - Args: - file: Path to the file - server_object: Optional object name in format "module:object" or just "object" - - Returns: - The server object - """ - # Add parent directory to Python path so imports can be resolved - file_dir = str(file.parent) - if file_dir not in sys.path: - sys.path.insert(0, file_dir) - - # Import the module - spec = importlib.util.spec_from_file_location("server_module", file) - if not spec or not spec.loader: - logger.error("Could not load module", extra={"file": str(file)}) - sys.exit(1) - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - def _check_server_object(server_object: Any, object_name: str): - """Helper function to check that the server object is supported - - Args: - server_object: The server object to check. - - Returns: - True if it's supported. - """ - if not isinstance(server_object, FastMCP): - logger.error(f"The server object {object_name} is of type {type(server_object)} (expecting {FastMCP}).") - if isinstance(server_object, LowLevelServer): - logger.warning( - "Note that only FastMCP server is supported. Low level Server class is not yet supported." - ) - return False - return True - - # If no object specified, try common server names - if not server_object: - # Look for the most common server object names - for name in ["mcp", "server", "app"]: - if hasattr(module, name): - if not _check_server_object(getattr(module, name), f"{file}:{name}"): - logger.error(f"Ignoring object '{file}:{name}' as it's not a valid server object") - continue - return getattr(module, name) - - logger.error( - f"No server object found in {file}. Please either:\n" - "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the object name with file:object syntax" - "3. If the server creates the FastMCP object within main() " - " or another function, refactor the FastMCP object to be a " - " global variable named mcp, server, or app.", - extra={"file": str(file)}, - ) - sys.exit(1) - - # Handle module:object syntax - if ":" in server_object: - module_name, object_name = server_object.split(":", 1) - try: - server_module = importlib.import_module(module_name) - server = getattr(server_module, object_name, None) - except ImportError: - logger.error( - f"Could not import module '{module_name}'", - extra={"file": str(file)}, - ) - sys.exit(1) - else: - # Just object name - server = getattr(module, server_object, None) - - if server is None: - logger.error( - f"Server object '{server_object}' not found", - extra={"file": str(file)}, - ) - sys.exit(1) - - if not _check_server_object(server, server_object): - sys.exit(1) - - return server - - -@app.command() -def version() -> None: # pragma: no cover - """Show the MCP version.""" - try: - version = importlib.metadata.version("mcp") - print(f"MCP version {version}") - except importlib.metadata.PackageNotFoundError: - print("MCP version unknown (package not installed)") - sys.exit(1) - - -@app.command() -def dev( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), - with_editable: Annotated[ - Path | None, - typer.Option( - "--with-editable", - "-e", - help="Directory containing pyproject.toml to install in editable mode", - exists=True, - file_okay=False, - resolve_path=True, - ), - ] = None, - with_packages: Annotated[ - list[str], - typer.Option( - "--with", - help="Additional packages to install", - ), - ] = [], -) -> None: # pragma: no cover - """Run an MCP server with the MCP Inspector.""" - file, server_object = _parse_file_path(file_spec) - - logger.debug( - "Starting dev server", - extra={ - "file": str(file), - "server_object": server_object, - "with_editable": str(with_editable) if with_editable else None, - "with_packages": with_packages, - }, - ) - - try: - # Import server to get dependencies - server = _import_server(file, server_object) - if hasattr(server, "dependencies"): - with_packages = list(set(with_packages + server.dependencies)) - - uv_cmd = _build_uv_command(file_spec, with_editable, with_packages) - - # Get the correct npx command - npx_cmd = _get_npx_command() - if not npx_cmd: - logger.error( - "npx not found. Please ensure Node.js and npm are properly installed and added to your system PATH." - ) - sys.exit(1) - - # Run the MCP Inspector command with shell=True on Windows - shell = sys.platform == "win32" - process = subprocess.run( - [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd, - check=True, - shell=shell, - env=dict(os.environ.items()), # Convert to list of tuples for env update - ) - sys.exit(process.returncode) - except subprocess.CalledProcessError as e: - logger.error( - "Dev server failed", - extra={ - "file": str(file), - "error": str(e), - "returncode": e.returncode, - }, - ) - sys.exit(e.returncode) - except FileNotFoundError: - logger.error( - "npx not found. Please ensure Node.js and npm are properly installed " - "and added to your system PATH. You may need to restart your terminal " - "after installation.", - extra={"file": str(file)}, - ) - sys.exit(1) - - -@app.command() -def run( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), - transport: Annotated[ - str | None, - typer.Option( - "--transport", - "-t", - help="Transport protocol to use (stdio or sse)", - ), - ] = None, -) -> None: # pragma: no cover - """Run an MCP server. - - The server can be specified in two ways:\n - 1. Module approach: server.py - runs the module directly, expecting a server.run() call.\n - 2. Import approach: server.py:app - imports and runs the specified server object.\n\n - - Note: This command runs the server directly. You are responsible for ensuring - all dependencies are available.\n - For dependency management, use `mcp install` or `mcp dev` instead. - """ # noqa: E501 - file, server_object = _parse_file_path(file_spec) - - logger.debug( - "Running server", - extra={ - "file": str(file), - "server_object": server_object, - "transport": transport, - }, - ) - - try: - # Import and get server object - server = _import_server(file, server_object) - - # Run the server - kwargs = {} - if transport: - kwargs["transport"] = transport - - server.run(**kwargs) - - except Exception: - logger.exception( - "Failed to run server", - extra={ - "file": str(file), - }, - ) - sys.exit(1) - - -@app.command() -def install( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), - server_name: Annotated[ - str | None, - typer.Option( - "--name", - "-n", - help="Custom name for the server (defaults to server's name attribute or file name)", - ), - ] = None, - with_editable: Annotated[ - Path | None, - typer.Option( - "--with-editable", - "-e", - help="Directory containing pyproject.toml to install in editable mode", - exists=True, - file_okay=False, - resolve_path=True, - ), - ] = None, - with_packages: Annotated[ - list[str], - typer.Option( - "--with", - help="Additional packages to install", - ), - ] = [], - env_vars: Annotated[ - list[str], - typer.Option( - "--env-var", - "-v", - help="Environment variables in KEY=VALUE format", - ), - ] = [], - env_file: Annotated[ - Path | None, - typer.Option( - "--env-file", - "-f", - help="Load environment variables from a .env file", - exists=True, - file_okay=True, - dir_okay=False, - resolve_path=True, - ), - ] = None, -) -> None: # pragma: no cover - """Install an MCP server in the Claude desktop app. - - Environment variables are preserved once added and only updated if new values - are explicitly provided. - """ - file, server_object = _parse_file_path(file_spec) - - logger.debug( - "Installing server", - extra={ - "file": str(file), - "server_name": server_name, - "server_object": server_object, - "with_editable": str(with_editable) if with_editable else None, - "with_packages": with_packages, - }, - ) - - if not claude.get_claude_config_path(): - logger.error("Claude app not found") - sys.exit(1) - - # Try to import server to get its name, but fall back to file name if dependencies - # missing - name = server_name - server = None - if not name: - try: - server = _import_server(file, server_object) - name = server.name - except (ImportError, ModuleNotFoundError) as e: - logger.debug( - "Could not import server (likely missing dependencies), using file name", - extra={"error": str(e)}, - ) - name = file.stem - - # Get server dependencies if available - server_dependencies = getattr(server, "dependencies", []) if server else [] - if server_dependencies: - with_packages = list(set(with_packages + server_dependencies)) - - # Process environment variables if provided - env_dict: dict[str, str] | None = None - if env_file or env_vars: - env_dict = {} - # Load from .env file if specified - if env_file: - if dotenv: - try: - env_dict |= {k: v for k, v in dotenv.dotenv_values(env_file).items() if v is not None} - except (OSError, ValueError): - logger.exception("Failed to load .env file") - sys.exit(1) - else: - logger.error("python-dotenv is not installed. Cannot load .env file.") - sys.exit(1) - - # Add command line environment variables - for env_var in env_vars: - key, value = _parse_env_var(env_var) - env_dict[key] = value - - if claude.update_claude_config( - file_spec, - name, - with_editable=with_editable, - with_packages=with_packages, - env_vars=env_dict, - ): - logger.info(f"Successfully installed {name} in Claude app") - else: - logger.error(f"Failed to install {name} in Claude app") - sys.exit(1) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/client/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/__main__.py b/src/agent_workflow/mcp/sdk/src/mcp/client/__main__.py deleted file mode 100644 index 2efe05d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/__main__.py +++ /dev/null @@ -1,85 +0,0 @@ -import argparse -import logging -import sys -from functools import partial -from urllib.parse import urlparse - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream - -import mcp.types as types -from mcp.client.session import ClientSession -from mcp.client.sse import sse_client -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder - -if not sys.warnoptions: - import warnings - - warnings.simplefilter("ignore") - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("client") - - -async def message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, -) -> None: - if isinstance(message, Exception): - logger.error("Error: %s", message) - return - - logger.info("Received message from server: %s", message) - - -async def run_session( - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], - client_info: types.Implementation | None = None, -): - async with ClientSession( - read_stream, - write_stream, - message_handler=message_handler, - client_info=client_info, - ) as session: - logger.info("Initializing session") - await session.initialize() - logger.info("Initialized") - - -async def main(command_or_url: str, args: list[str], env: list[tuple[str, str]]): - env_dict = dict(env) - - if urlparse(command_or_url).scheme in ("http", "https"): - # Use SSE client for HTTP(S) URLs - async with sse_client(command_or_url) as streams: - await run_session(*streams) - else: - # Use stdio client for commands - server_parameters = StdioServerParameters(command=command_or_url, args=args, env=env_dict) - async with stdio_client(server_parameters) as streams: - await run_session(*streams) - - -def cli(): - parser = argparse.ArgumentParser() - parser.add_argument("command_or_url", help="Command or URL to connect to") - parser.add_argument("args", nargs="*", help="Additional arguments") - parser.add_argument( - "-e", - "--env", - nargs=2, - action="append", - metavar=("KEY", "VALUE"), - help="Environment variables to set. Can be used multiple times.", - default=[], - ) - - args = parser.parse_args() - anyio.run(partial(main, args.command_or_url, args.args, args.env), backend="trio") - - -if __name__ == "__main__": - cli() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/client/auth/__init__.py deleted file mode 100644 index 252dfd9..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -OAuth2 Authentication implementation for HTTPX. - -Implements authorization code flow with PKCE and automatic token refresh. -""" - -from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError -from mcp.client.auth.oauth2 import ( - OAuthClientProvider, - PKCEParameters, - TokenStorage, -) - -__all__ = [ - "OAuthClientProvider", - "OAuthFlowError", - "OAuthRegistrationError", - "OAuthTokenError", - "PKCEParameters", - "TokenStorage", -] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/exceptions.py b/src/agent_workflow/mcp/sdk/src/mcp/client/auth/exceptions.py deleted file mode 100644 index 5ce8777..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/exceptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class OAuthFlowError(Exception): - """Base exception for OAuth flow errors.""" - - -class OAuthTokenError(OAuthFlowError): - """Raised when token operations fail.""" - - -class OAuthRegistrationError(OAuthFlowError): - """Raised when client registration fails.""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/client_credentials.py b/src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/client_credentials.py deleted file mode 100644 index e2f3f08..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/extensions/client_credentials.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -OAuth client credential extensions for MCP. - -Provides OAuth providers for machine-to-machine authentication flows: -- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret -- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication - (typically using a pre-built JWT from workload identity federation) -- RFC7523OAuthClientProvider: For jwt-bearer grant (RFC 7523 Section 2.1) -""" - -import time -from collections.abc import Awaitable, Callable -from typing import Any, Literal -from uuid import uuid4 - -import httpx -import jwt -from pydantic import BaseModel, Field - -from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata - - -class ClientCredentialsOAuthProvider(OAuthClientProvider): - """OAuth provider for client_credentials grant with client_id + client_secret. - - This provider sets client_info directly, bypassing dynamic client registration. - Use this when you already have client credentials (client_id and client_secret). - - Example: - ```python - provider = ClientCredentialsOAuthProvider( - server_url="https://api.example.com", - storage=my_token_storage, - client_id="my-client-id", - client_secret="my-client-secret", - ) - ``` - """ - - def __init__( - self, - server_url: str, - storage: TokenStorage, - client_id: str, - client_secret: str, - token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic", - scopes: str | None = None, - ) -> None: - """Initialize client_credentials OAuth provider. - - Args: - server_url: The MCP server URL. - storage: Token storage implementation. - client_id: The OAuth client ID. - client_secret: The OAuth client secret. - token_endpoint_auth_method: Authentication method for token endpoint. - Either "client_secret_basic" (default) or "client_secret_post". - scopes: Optional space-separated list of scopes to request. - """ - # Build minimal client_metadata for the base class - client_metadata = OAuthClientMetadata( - redirect_uris=None, - grant_types=["client_credentials"], - token_endpoint_auth_method=token_endpoint_auth_method, - scope=scopes, - ) - super().__init__(server_url, client_metadata, storage, None, None, 300.0) - # Store client_info to be set during _initialize - no dynamic registration needed - self._fixed_client_info = OAuthClientInformationFull( - redirect_uris=None, - client_id=client_id, - client_secret=client_secret, - grant_types=["client_credentials"], - token_endpoint_auth_method=token_endpoint_auth_method, - scope=scopes, - ) - - async def _initialize(self) -> None: - """Load stored tokens and set pre-configured client_info.""" - self.context.current_tokens = await self.context.storage.get_tokens() - self.context.client_info = self._fixed_client_info - self._initialized = True - - async def _perform_authorization(self) -> httpx.Request: - """Perform client_credentials authorization.""" - return await self._exchange_token_client_credentials() - - async def _exchange_token_client_credentials(self) -> httpx.Request: - """Build token exchange request for client_credentials grant.""" - token_data: dict[str, Any] = { - "grant_type": "client_credentials", - } - - headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} - - # Use standard auth methods (client_secret_basic, client_secret_post, none) - token_data, headers = self.context.prepare_token_auth(token_data, headers) - - if self.context.should_include_resource_param(self.context.protocol_version): - token_data["resource"] = self.context.get_resource_url() - - if self.context.client_metadata.scope: - token_data["scope"] = self.context.client_metadata.scope - - token_url = self._get_token_endpoint() - return httpx.Request("POST", token_url, data=token_data, headers=headers) - - -def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: - """Create an assertion provider that returns a static JWT token. - - Use this when you have a pre-built JWT (e.g., from workload identity federation) - that doesn't need the audience parameter. - - Example: - ```python - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=my_token_storage, - client_id="my-client-id", - assertion_provider=static_assertion_provider(my_prebuilt_jwt), - ) - ``` - - Args: - token: The pre-built JWT assertion string. - - Returns: - An async callback suitable for use as an assertion_provider. - """ - - async def provider(audience: str) -> str: - return token - - return provider - - -class SignedJWTParameters(BaseModel): - """Parameters for creating SDK-signed JWT assertions. - - Use `create_assertion_provider()` to create an assertion provider callback - for use with `PrivateKeyJWTOAuthProvider`. - - Example: - ```python - jwt_params = SignedJWTParameters( - issuer="my-client-id", - subject="my-client-id", - signing_key=private_key_pem, - ) - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=my_token_storage, - client_id="my-client-id", - assertion_provider=jwt_params.create_assertion_provider(), - ) - ``` - """ - - issuer: str = Field(description="Issuer for JWT assertions (typically client_id).") - subject: str = Field(description="Subject identifier for JWT assertions (typically client_id).") - signing_key: str = Field(description="Private key for JWT signing (PEM format).") - signing_algorithm: str = Field(default="RS256", description="Algorithm for signing JWT assertions.") - lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.") - additional_claims: dict[str, Any] | None = Field(default=None, description="Additional claims.") - - def create_assertion_provider(self) -> Callable[[str], Awaitable[str]]: - """Create an assertion provider callback for use with PrivateKeyJWTOAuthProvider. - - Returns: - An async callback that takes the audience (authorization server issuer URL) - and returns a signed JWT assertion. - """ - - async def provider(audience: str) -> str: - now = int(time.time()) - claims: dict[str, Any] = { - "iss": self.issuer, - "sub": self.subject, - "aud": audience, - "exp": now + self.lifetime_seconds, - "iat": now, - "jti": str(uuid4()), - } - if self.additional_claims: - claims.update(self.additional_claims) - - return jwt.encode(claims, self.signing_key, algorithm=self.signing_algorithm) - - return provider - - -class PrivateKeyJWTOAuthProvider(OAuthClientProvider): - """OAuth provider for client_credentials grant with private_key_jwt authentication. - - Uses RFC 7523 Section 2.2 for client authentication via JWT assertion. - - The JWT assertion's audience MUST be the authorization server's issuer identifier - (per RFC 7523bis security updates). The `assertion_provider` callback receives - this audience value and must return a JWT with that audience. - - **Option 1: Pre-built JWT via Workload Identity Federation** - - In production scenarios, the JWT assertion is typically obtained from a workload - identity provider (e.g., GCP, AWS IAM, Azure AD): - - ```python - async def get_workload_identity_token(audience: str) -> str: - # Fetch JWT from your identity provider - # The JWT's audience must match the provided audience parameter - return await fetch_token_from_identity_provider(audience=audience) - - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=my_token_storage, - client_id="my-client-id", - assertion_provider=get_workload_identity_token, - ) - ``` - - **Option 2: Static pre-built JWT** - - If you have a static JWT that doesn't need the audience parameter: - - ```python - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=my_token_storage, - client_id="my-client-id", - assertion_provider=static_assertion_provider(my_prebuilt_jwt), - ) - ``` - - **Option 3: SDK-signed JWT (for testing/simple setups)** - - For testing or simple deployments, use `SignedJWTParameters.create_assertion_provider()`: - - ```python - jwt_params = SignedJWTParameters( - issuer="my-client-id", - subject="my-client-id", - signing_key=private_key_pem, - ) - provider = PrivateKeyJWTOAuthProvider( - server_url="https://api.example.com", - storage=my_token_storage, - client_id="my-client-id", - assertion_provider=jwt_params.create_assertion_provider(), - ) - ``` - """ - - def __init__( - self, - server_url: str, - storage: TokenStorage, - client_id: str, - assertion_provider: Callable[[str], Awaitable[str]], - scopes: str | None = None, - ) -> None: - """Initialize private_key_jwt OAuth provider. - - Args: - server_url: The MCP server URL. - storage: Token storage implementation. - client_id: The OAuth client ID. - assertion_provider: Async callback that takes the audience (authorization - server's issuer identifier) and returns a JWT assertion. Use - `SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs, - `static_assertion_provider()` for pre-built JWTs, or provide your own - callback for workload identity federation. - scopes: Optional space-separated list of scopes to request. - """ - # Build minimal client_metadata for the base class - client_metadata = OAuthClientMetadata( - redirect_uris=None, - grant_types=["client_credentials"], - token_endpoint_auth_method="private_key_jwt", - scope=scopes, - ) - super().__init__(server_url, client_metadata, storage, None, None, 300.0) - self._assertion_provider = assertion_provider - # Store client_info to be set during _initialize - no dynamic registration needed - self._fixed_client_info = OAuthClientInformationFull( - redirect_uris=None, - client_id=client_id, - grant_types=["client_credentials"], - token_endpoint_auth_method="private_key_jwt", - scope=scopes, - ) - - async def _initialize(self) -> None: - """Load stored tokens and set pre-configured client_info.""" - self.context.current_tokens = await self.context.storage.get_tokens() - self.context.client_info = self._fixed_client_info - self._initialized = True - - async def _perform_authorization(self) -> httpx.Request: - """Perform client_credentials authorization with private_key_jwt.""" - return await self._exchange_token_client_credentials() - - async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) -> None: - """Add JWT assertion for client authentication to token endpoint parameters.""" - if not self.context.oauth_metadata: - raise OAuthFlowError("Missing OAuth metadata for private_key_jwt flow") # pragma: no cover - - # Audience MUST be the issuer identifier of the authorization server - # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01 - audience = str(self.context.oauth_metadata.issuer) - assertion = await self._assertion_provider(audience) - - # RFC 7523 Section 2.2: client authentication via JWT - token_data["client_assertion"] = assertion - token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - - async def _exchange_token_client_credentials(self) -> httpx.Request: - """Build token exchange request for client_credentials grant with private_key_jwt.""" - token_data: dict[str, Any] = { - "grant_type": "client_credentials", - } - - headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} - - # Add JWT client authentication (RFC 7523 Section 2.2) - await self._add_client_authentication_jwt(token_data=token_data) - - if self.context.should_include_resource_param(self.context.protocol_version): - token_data["resource"] = self.context.get_resource_url() - - if self.context.client_metadata.scope: - token_data["scope"] = self.context.client_metadata.scope - - token_url = self._get_token_endpoint() - return httpx.Request("POST", token_url, data=token_data, headers=headers) - - -class JWTParameters(BaseModel): - """JWT parameters.""" - - assertion: str | None = Field( - default=None, - description="JWT assertion for JWT authentication. " - "Will be used instead of generating a new assertion if provided.", - ) - - issuer: str | None = Field(default=None, description="Issuer for JWT assertions.") - subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.") - audience: str | None = Field(default=None, description="Audience for JWT assertions.") - claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.") - jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.") - jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.") - jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.") - - def to_assertion(self, with_audience_fallback: str | None = None) -> str: - if self.assertion is not None: - # Prebuilt JWT (e.g. acquired out-of-band) - assertion = self.assertion - else: - if not self.jwt_signing_key: - raise OAuthFlowError("Missing signing key for JWT bearer grant") # pragma: no cover - if not self.issuer: - raise OAuthFlowError("Missing issuer for JWT bearer grant") # pragma: no cover - if not self.subject: - raise OAuthFlowError("Missing subject for JWT bearer grant") # pragma: no cover - - audience = self.audience if self.audience else with_audience_fallback - if not audience: - raise OAuthFlowError("Missing audience for JWT bearer grant") # pragma: no cover - - now = int(time.time()) - claims: dict[str, Any] = { - "iss": self.issuer, - "sub": self.subject, - "aud": audience, - "exp": now + self.jwt_lifetime_seconds, - "iat": now, - "jti": str(uuid4()), - } - claims.update(self.claims or {}) - - assertion = jwt.encode( - claims, - self.jwt_signing_key, - algorithm=self.jwt_signing_algorithm or "RS256", - ) - return assertion - - -class RFC7523OAuthClientProvider(OAuthClientProvider): - """OAuth client provider for RFC 7523 jwt-bearer grant. - - .. deprecated:: - Use :class:`ClientCredentialsOAuthProvider` for client_credentials with - client_id + client_secret, or :class:`PrivateKeyJWTOAuthProvider` for - client_credentials with private_key_jwt authentication instead. - - This provider supports the jwt-bearer authorization grant (RFC 7523 Section 2.1) - where the JWT itself is the authorization grant. - """ - - def __init__( - self, - server_url: str, - client_metadata: OAuthClientMetadata, - storage: TokenStorage, - redirect_handler: Callable[[str], Awaitable[None]] | None = None, - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, - timeout: float = 300.0, - jwt_parameters: JWTParameters | None = None, - ) -> None: - import warnings - - warnings.warn( - "RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider " - "or PrivateKeyJWTOAuthProvider instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout) - self.jwt_parameters = jwt_parameters - - async def _exchange_token_authorization_code( - self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None - ) -> httpx.Request: # pragma: no cover - """Build token exchange request for authorization_code flow.""" - token_data = token_data or {} - if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt": - self._add_client_authentication_jwt(token_data=token_data) - return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data) - - async def _perform_authorization(self) -> httpx.Request: # pragma: no cover - """Perform the authorization flow.""" - if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types: - token_request = await self._exchange_token_jwt_bearer() - return token_request - else: - return await super()._perform_authorization() - - def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # pragma: no cover - """Add JWT assertion for client authentication to token endpoint parameters.""" - if not self.jwt_parameters: - raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow") - if not self.context.oauth_metadata: - raise OAuthTokenError("Missing OAuth metadata for private_key_jwt flow") - - # We need to set the audience to the issuer identifier of the authorization server - # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523 - issuer = str(self.context.oauth_metadata.issuer) - assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer) - - # When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2 - token_data["client_assertion"] = assertion - token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - # We need to set the audience to the resource server, the audience is difference from the one in claims - # it represents the resource server that will validate the token - token_data["audience"] = self.context.get_resource_url() - - async def _exchange_token_jwt_bearer(self) -> httpx.Request: - """Build token exchange request for JWT bearer grant.""" - if not self.context.client_info: - raise OAuthFlowError("Missing client info") # pragma: no cover - if not self.jwt_parameters: - raise OAuthFlowError("Missing JWT parameters") # pragma: no cover - if not self.context.oauth_metadata: - raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover - - # We need to set the audience to the issuer identifier of the authorization server - # https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523 - issuer = str(self.context.oauth_metadata.issuer) - assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer) - - token_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", - "assertion": assertion, - } - - if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch - token_data["resource"] = self.context.get_resource_url() - - if self.context.client_metadata.scope: # pragma: no branch - token_data["scope"] = self.context.client_metadata.scope - - token_url = self._get_token_endpoint() - return httpx.Request( - "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/oauth2.py b/src/agent_workflow/mcp/sdk/src/mcp/client/auth/oauth2.py deleted file mode 100644 index 0ec0879..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/oauth2.py +++ /dev/null @@ -1,627 +0,0 @@ -""" -OAuth2 Authentication implementation for HTTPX. - -Implements authorization code flow with PKCE and automatic token refresh. -""" - -import base64 -import hashlib -import logging -import secrets -import string -import time -from collections.abc import AsyncGenerator, Awaitable, Callable -from dataclasses import dataclass, field -from typing import Any, Protocol -from urllib.parse import quote, urlencode, urljoin, urlparse - -import anyio -import httpx -from pydantic import BaseModel, Field, ValidationError - -from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError -from mcp.client.auth.utils import ( - build_oauth_authorization_server_metadata_discovery_urls, - build_protected_resource_metadata_discovery_urls, - create_client_info_from_metadata_url, - create_client_registration_request, - create_oauth_metadata_request, - extract_field_from_www_auth, - extract_resource_metadata_from_www_auth, - extract_scope_from_www_auth, - get_client_metadata_scopes, - handle_auth_metadata_response, - handle_protected_resource_response, - handle_registration_response, - handle_token_response_scopes, - is_valid_client_metadata_url, - should_use_client_metadata_url, -) -from mcp.client.streamable_http import MCP_PROTOCOL_VERSION -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthClientMetadata, - OAuthMetadata, - OAuthToken, - ProtectedResourceMetadata, -) -from mcp.shared.auth_utils import ( - calculate_token_expiry, - check_resource_allowed, - resource_url_from_server_url, -) - -logger = logging.getLogger(__name__) - - -class PKCEParameters(BaseModel): - """PKCE (Proof Key for Code Exchange) parameters.""" - - code_verifier: str = Field(..., min_length=43, max_length=128) - code_challenge: str = Field(..., min_length=43, max_length=128) - - @classmethod - def generate(cls) -> "PKCEParameters": - """Generate new PKCE parameters.""" - code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128)) - digest = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") - return cls(code_verifier=code_verifier, code_challenge=code_challenge) - - -class TokenStorage(Protocol): - """Protocol for token storage implementations.""" - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - ... - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - ... - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - ... - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - ... - - -@dataclass -class OAuthContext: - """OAuth flow context.""" - - server_url: str - client_metadata: OAuthClientMetadata - storage: TokenStorage - redirect_handler: Callable[[str], Awaitable[None]] | None - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None - timeout: float = 300.0 - client_metadata_url: str | None = None - - # Discovered metadata - protected_resource_metadata: ProtectedResourceMetadata | None = None - oauth_metadata: OAuthMetadata | None = None - auth_server_url: str | None = None - protocol_version: str | None = None - - # Client registration - client_info: OAuthClientInformationFull | None = None - - # Token management - current_tokens: OAuthToken | None = None - token_expiry_time: float | None = None - - # State - lock: anyio.Lock = field(default_factory=anyio.Lock) - - def get_authorization_base_url(self, server_url: str) -> str: - """Extract base URL by removing path component.""" - parsed = urlparse(server_url) - return f"{parsed.scheme}://{parsed.netloc}" - - def update_token_expiry(self, token: OAuthToken) -> None: - """Update token expiry time using shared util function.""" - self.token_expiry_time = calculate_token_expiry(token.expires_in) - - def is_token_valid(self) -> bool: - """Check if current token is valid.""" - return bool( - self.current_tokens - and self.current_tokens.access_token - and (not self.token_expiry_time or time.time() <= self.token_expiry_time) - ) - - def can_refresh_token(self) -> bool: - """Check if token can be refreshed.""" - return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info) - - def clear_tokens(self) -> None: - """Clear current tokens.""" - self.current_tokens = None - self.token_expiry_time = None - - def get_resource_url(self) -> str: - """Get resource URL for RFC 8707. - - Uses PRM resource if it's a valid parent, otherwise uses canonical server URL. - """ - resource = resource_url_from_server_url(self.server_url) - - # If PRM provides a resource that's a valid parent, use it - if self.protected_resource_metadata and self.protected_resource_metadata.resource: - prm_resource = str(self.protected_resource_metadata.resource) - if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource): - resource = prm_resource - - return resource - - def should_include_resource_param(self, protocol_version: str | None = None) -> bool: - """Determine if the resource parameter should be included in OAuth requests. - - Returns True if: - - Protected resource metadata is available, OR - - MCP-Protocol-Version header is 2025-06-18 or later - """ - # If we have protected resource metadata, include the resource param - if self.protected_resource_metadata is not None: - return True - - # If no protocol version provided, don't include resource param - if not protocol_version: - return False - - # Check if protocol version is 2025-06-18 or later - # Version format is YYYY-MM-DD, so string comparison works - return protocol_version >= "2025-06-18" - - def prepare_token_auth( - self, data: dict[str, str], headers: dict[str, str] | None = None - ) -> tuple[dict[str, str], dict[str, str]]: - """Prepare authentication for token requests. - - Args: - data: The form data to send - headers: Optional headers dict to update - - Returns: - Tuple of (updated_data, updated_headers) - """ - if headers is None: - headers = {} # pragma: no cover - - if not self.client_info: - return data, headers # pragma: no cover - - auth_method = self.client_info.token_endpoint_auth_method - - if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret: - # URL-encode client ID and secret per RFC 6749 Section 2.3.1 - encoded_id = quote(self.client_info.client_id, safe="") - encoded_secret = quote(self.client_info.client_secret, safe="") - credentials = f"{encoded_id}:{encoded_secret}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers["Authorization"] = f"Basic {encoded_credentials}" - # Don't include client_secret in body for basic auth - data = {k: v for k, v in data.items() if k != "client_secret"} - elif auth_method == "client_secret_post" and self.client_info.client_secret: - # Include client_secret in request body - data["client_secret"] = self.client_info.client_secret - # For auth_method == "none", don't add any client_secret - - return data, headers - - -class OAuthClientProvider(httpx.Auth): - """ - OAuth2 authentication for httpx. - Handles OAuth flow with automatic client registration and token storage. - """ - - requires_response_body = True - - def __init__( - self, - server_url: str, - client_metadata: OAuthClientMetadata, - storage: TokenStorage, - redirect_handler: Callable[[str], Awaitable[None]] | None = None, - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, - timeout: float = 300.0, - client_metadata_url: str | None = None, - ): - """Initialize OAuth2 authentication. - - Args: - server_url: The MCP server URL. - client_metadata: OAuth client metadata for registration. - storage: Token storage implementation. - redirect_handler: Handler for authorization redirects. - callback_handler: Handler for authorization callbacks. - timeout: Timeout for the OAuth flow. - client_metadata_url: URL-based client ID. When provided and the server - advertises client_id_metadata_document_supported=true, this URL will be - used as the client_id instead of performing dynamic client registration. - Must be a valid HTTPS URL with a non-root pathname. - - Raises: - ValueError: If client_metadata_url is provided but not a valid HTTPS URL - with a non-root pathname. - """ - # Validate client_metadata_url if provided - if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url): - raise ValueError( - f"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {client_metadata_url}" - ) - - self.context = OAuthContext( - server_url=server_url, - client_metadata=client_metadata, - storage=storage, - redirect_handler=redirect_handler, - callback_handler=callback_handler, - timeout=timeout, - client_metadata_url=client_metadata_url, - ) - self._initialized = False - - async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: - """Validate that PRM resource matches the server URL per RFC 8707.""" - prm_resource = str(prm.resource) if prm.resource else None - if not prm_resource: - return # pragma: no cover - default_resource = resource_url_from_server_url(self.context.server_url) - if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): - raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") - - async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: - """ - Handle protected resource metadata discovery response. - - Per SEP-985, supports fallback when discovery fails at one URL. - - Returns: - True if metadata was successfully discovered, False if we should try next URL - """ - if response.status_code == 200: - try: - content = await response.aread() - metadata = ProtectedResourceMetadata.model_validate_json(content) - self.context.protected_resource_metadata = metadata - if metadata.authorization_servers: # pragma: no branch - self.context.auth_server_url = str(metadata.authorization_servers[0]) - return True - - except ValidationError: # pragma: no cover - # Invalid metadata - try next URL - logger.warning(f"Invalid protected resource metadata at {response.request.url}") - return False - elif response.status_code == 404: # pragma: no cover - # Not found - try next URL in fallback chain - logger.debug(f"Protected resource metadata not found at {response.request.url}, trying next URL") - return False - else: - # Other error - fail immediately - raise OAuthFlowError( - f"Protected Resource Metadata request failed: {response.status_code}" - ) # pragma: no cover - - async def _perform_authorization(self) -> httpx.Request: - """Perform the authorization flow.""" - auth_code, code_verifier = await self._perform_authorization_code_grant() - token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) - return token_request - - async def _perform_authorization_code_grant(self) -> tuple[str, str]: - """Perform the authorization redirect and get auth code.""" - if self.context.client_metadata.redirect_uris is None: - raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover - if not self.context.redirect_handler: - raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover - if not self.context.callback_handler: - raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover - - if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: - auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) # pragma: no cover - else: - auth_base_url = self.context.get_authorization_base_url(self.context.server_url) - auth_endpoint = urljoin(auth_base_url, "/authorize") - - if not self.context.client_info: - raise OAuthFlowError("No client info available for authorization") # pragma: no cover - - # Generate PKCE parameters - pkce_params = PKCEParameters.generate() - state = secrets.token_urlsafe(32) - - auth_params = { - "response_type": "code", - "client_id": self.context.client_info.client_id, - "redirect_uri": str(self.context.client_metadata.redirect_uris[0]), - "state": state, - "code_challenge": pkce_params.code_challenge, - "code_challenge_method": "S256", - } - - # Only include resource param if conditions are met - if self.context.should_include_resource_param(self.context.protocol_version): - auth_params["resource"] = self.context.get_resource_url() # RFC 8707 # pragma: no cover - - if self.context.client_metadata.scope: # pragma: no branch - auth_params["scope"] = self.context.client_metadata.scope - - authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" - await self.context.redirect_handler(authorization_url) - - # Wait for callback - auth_code, returned_state = await self.context.callback_handler() - - if returned_state is None or not secrets.compare_digest(returned_state, state): - raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}") # pragma: no cover - - if not auth_code: - raise OAuthFlowError("No authorization code received") # pragma: no cover - - # Return auth code and code verifier for token exchange - return auth_code, pkce_params.code_verifier - - def _get_token_endpoint(self) -> str: - if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: - token_url = str(self.context.oauth_metadata.token_endpoint) - else: - auth_base_url = self.context.get_authorization_base_url(self.context.server_url) - token_url = urljoin(auth_base_url, "/token") - return token_url - - async def _exchange_token_authorization_code( - self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {} - ) -> httpx.Request: - """Build token exchange request for authorization_code flow.""" - if self.context.client_metadata.redirect_uris is None: - raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover - if not self.context.client_info: - raise OAuthFlowError("Missing client info") # pragma: no cover - - token_url = self._get_token_endpoint() - token_data = token_data or {} - token_data.update( - { - "grant_type": "authorization_code", - "code": auth_code, - "redirect_uri": str(self.context.client_metadata.redirect_uris[0]), - "client_id": self.context.client_info.client_id, - "code_verifier": code_verifier, - } - ) - - # Only include resource param if conditions are met - if self.context.should_include_resource_param(self.context.protocol_version): - token_data["resource"] = self.context.get_resource_url() # RFC 8707 - - # Prepare authentication based on preferred method - headers = {"Content-Type": "application/x-www-form-urlencoded"} - token_data, headers = self.context.prepare_token_auth(token_data, headers) - - return httpx.Request("POST", token_url, data=token_data, headers=headers) - - async def _handle_token_response(self, response: httpx.Response) -> None: - """Handle token exchange response.""" - if response.status_code != 200: - body = await response.aread() # pragma: no cover - body_text = body.decode("utf-8") # pragma: no cover - raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover - - # Parse and validate response with scope validation - token_response = await handle_token_response_scopes(response) - - # Store tokens in context - self.context.current_tokens = token_response - self.context.update_token_expiry(token_response) - await self.context.storage.set_tokens(token_response) - - async def _refresh_token(self) -> httpx.Request: - """Build token refresh request.""" - if not self.context.current_tokens or not self.context.current_tokens.refresh_token: - raise OAuthTokenError("No refresh token available") # pragma: no cover - - if not self.context.client_info or not self.context.client_info.client_id: - raise OAuthTokenError("No client info available") # pragma: no cover - - if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: - token_url = str(self.context.oauth_metadata.token_endpoint) # pragma: no cover - else: - auth_base_url = self.context.get_authorization_base_url(self.context.server_url) - token_url = urljoin(auth_base_url, "/token") - - refresh_data: dict[str, str] = { - "grant_type": "refresh_token", - "refresh_token": self.context.current_tokens.refresh_token, - "client_id": self.context.client_info.client_id, - } - - # Only include resource param if conditions are met - if self.context.should_include_resource_param(self.context.protocol_version): - refresh_data["resource"] = self.context.get_resource_url() # RFC 8707 - - # Prepare authentication based on preferred method - headers = {"Content-Type": "application/x-www-form-urlencoded"} - refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers) - - return httpx.Request("POST", token_url, data=refresh_data, headers=headers) - - async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover - """Handle token refresh response. Returns True if successful.""" - if response.status_code != 200: - logger.warning(f"Token refresh failed: {response.status_code}") - self.context.clear_tokens() - return False - - try: - content = await response.aread() - token_response = OAuthToken.model_validate_json(content) - - self.context.current_tokens = token_response - self.context.update_token_expiry(token_response) - await self.context.storage.set_tokens(token_response) - - return True - except ValidationError: - logger.exception("Invalid refresh response") - self.context.clear_tokens() - return False - - async def _initialize(self) -> None: # pragma: no cover - """Load stored tokens and client info.""" - self.context.current_tokens = await self.context.storage.get_tokens() - self.context.client_info = await self.context.storage.get_client_info() - self._initialized = True - - def _add_auth_header(self, request: httpx.Request) -> None: - """Add authorization header to request if we have valid tokens.""" - if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch - request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}" - - async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None: - content = await response.aread() - metadata = OAuthMetadata.model_validate_json(content) - self.context.oauth_metadata = metadata - - async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: - """HTTPX auth flow integration.""" - async with self.context.lock: - if not self._initialized: - await self._initialize() # pragma: no cover - - # Capture protocol version from request headers - self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) - - if not self.context.is_token_valid() and self.context.can_refresh_token(): - # Try to refresh token - refresh_request = await self._refresh_token() # pragma: no cover - refresh_response = yield refresh_request # pragma: no cover - - if not await self._handle_refresh_response(refresh_response): # pragma: no cover - # Refresh failed, need full re-authentication - self._initialized = False - - if self.context.is_token_valid(): - self._add_auth_header(request) - - response = yield request - - if response.status_code == 401: - # Perform full OAuth flow - try: - # OAuth flow must be inline due to generator constraints - www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response) - - # Step 1: Discover protected resource metadata (SEP-985 with fallback support) - prm_discovery_urls = build_protected_resource_metadata_discovery_urls( - www_auth_resource_metadata_url, self.context.server_url - ) - - for url in prm_discovery_urls: # pragma: no branch - discovery_request = create_oauth_metadata_request(url) - - discovery_response = yield discovery_request # sending request - - prm = await handle_protected_resource_response(discovery_response) - if prm: - # Validate PRM resource matches server URL (RFC 8707) - await self._validate_resource_match(prm) - self.context.protected_resource_metadata = prm - - # todo: try all authorization_servers to find the OASM - assert ( - len(prm.authorization_servers) > 0 - ) # this is always true as authorization_servers has a min length of 1 - - self.context.auth_server_url = str(prm.authorization_servers[0]) - break - else: - logger.debug(f"Protected resource metadata discovery failed: {url}") - - asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( - self.context.auth_server_url, self.context.server_url - ) - - # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) - for url in asm_discovery_urls: # pragma: no cover - oauth_metadata_request = create_oauth_metadata_request(url) - oauth_metadata_response = yield oauth_metadata_request - - ok, asm = await handle_auth_metadata_response(oauth_metadata_response) - if not ok: - break - if ok and asm: - self.context.oauth_metadata = asm - break - else: - logger.debug(f"OAuth metadata discovery failed: {url}") - - # Step 3: Apply scope selection strategy - self.context.client_metadata.scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), - self.context.protected_resource_metadata, - self.context.oauth_metadata, - ) - - # Step 4: Register client or use URL-based client ID (CIMD) - if not self.context.client_info: - if should_use_client_metadata_url( - self.context.oauth_metadata, self.context.client_metadata_url - ): - # Use URL-based client ID (CIMD) - logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}") - client_information = create_client_info_from_metadata_url( - self.context.client_metadata_url, # type: ignore[arg-type] - redirect_uris=self.context.client_metadata.redirect_uris, - ) - self.context.client_info = client_information - await self.context.storage.set_client_info(client_information) - else: - # Fallback to Dynamic Client Registration - registration_request = create_client_registration_request( - self.context.oauth_metadata, - self.context.client_metadata, - self.context.get_authorization_base_url(self.context.server_url), - ) - registration_response = yield registration_request - client_information = await handle_registration_response(registration_response) - self.context.client_info = client_information - await self.context.storage.set_client_info(client_information) - - # Step 5: Perform authorization and complete token exchange - token_response = yield await self._perform_authorization() - await self._handle_token_response(token_response) - except Exception: # pragma: no cover - logger.exception("OAuth flow error") - raise - - # Retry with new tokens - self._add_auth_header(request) - yield request - elif response.status_code == 403: - # Step 1: Extract error field from WWW-Authenticate header - error = extract_field_from_www_auth(response, "error") - - # Step 2: Check if we need to step-up authorization - if error == "insufficient_scope": # pragma: no branch - try: - # Step 2a: Update the required scopes - self.context.client_metadata.scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), self.context.protected_resource_metadata - ) - - # Step 2b: Perform (re-)authorization and token exchange - token_response = yield await self._perform_authorization() - await self._handle_token_response(token_response) - except Exception: # pragma: no cover - logger.exception("OAuth flow error") - raise - - # Retry with new tokens - self._add_auth_header(request) - yield request diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/utils.py b/src/agent_workflow/mcp/sdk/src/mcp/client/auth/utils.py deleted file mode 100644 index b4426be..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/auth/utils.py +++ /dev/null @@ -1,336 +0,0 @@ -import logging -import re -from urllib.parse import urljoin, urlparse - -from httpx import Request, Response -from pydantic import AnyUrl, ValidationError - -from mcp.client.auth import OAuthRegistrationError, OAuthTokenError -from mcp.client.streamable_http import MCP_PROTOCOL_VERSION -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthClientMetadata, - OAuthMetadata, - OAuthToken, - ProtectedResourceMetadata, -) -from mcp.types import LATEST_PROTOCOL_VERSION - -logger = logging.getLogger(__name__) - - -def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: - """ - Extract field from WWW-Authenticate header. - - Returns: - Field value if found in WWW-Authenticate header, None otherwise - """ - www_auth_header = response.headers.get("WWW-Authenticate") - if not www_auth_header: - return None - - # Pattern matches: field_name="value" or field_name=value (unquoted) - pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))' - match = re.search(pattern, www_auth_header) - - if match: - # Return quoted value if present, otherwise unquoted value - return match.group(1) or match.group(2) - - return None - - -def extract_scope_from_www_auth(response: Response) -> str | None: - """ - Extract scope parameter from WWW-Authenticate header as per RFC6750. - - Returns: - Scope string if found in WWW-Authenticate header, None otherwise - """ - return extract_field_from_www_auth(response, "scope") - - -def extract_resource_metadata_from_www_auth(response: Response) -> str | None: - """ - Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728. - - Returns: - Resource metadata URL if found in WWW-Authenticate header, None otherwise - """ - if not response or response.status_code != 401: - return None # pragma: no cover - - return extract_field_from_www_auth(response, "resource_metadata") - - -def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: - """ - Build ordered list of URLs to try for protected resource metadata discovery. - - Per SEP-985, the client MUST: - 1. Try resource_metadata from WWW-Authenticate header (if present) - 2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path} - 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource - - Args: - www_auth_url: optional resource_metadata url extracted from the WWW-Authenticate header - server_url: server url - - Returns: - Ordered list of URLs to try for discovery - """ - urls: list[str] = [] - - # Priority 1: WWW-Authenticate header with resource_metadata parameter - if www_auth_url: - urls.append(www_auth_url) - - # Priority 2-3: Well-known URIs (RFC 9728) - parsed = urlparse(server_url) - base_url = f"{parsed.scheme}://{parsed.netloc}" - - # Priority 2: Path-based well-known URI (if server has a path component) - if parsed.path and parsed.path != "/": - path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}") - urls.append(path_based_url) - - # Priority 3: Root-based well-known URI - root_based_url = urljoin(base_url, "/.well-known/oauth-protected-resource") - urls.append(root_based_url) - - return urls - - -def get_client_metadata_scopes( - www_authenticate_scope: str | None, - protected_resource_metadata: ProtectedResourceMetadata | None, - authorization_server_metadata: OAuthMetadata | None = None, -) -> str | None: - """Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec.""" - # Per MCP spec, scope selection priority order: - # 1. Use scope from WWW-Authenticate header (if provided) - # 2. Use all scopes from PRM scopes_supported (if available) - # 3. Omit scope parameter if neither is available - - if www_authenticate_scope is not None: - # Priority 1: WWW-Authenticate header scope - return www_authenticate_scope - elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: - # Priority 2: PRM scopes_supported - return " ".join(protected_resource_metadata.scopes_supported) - elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: - return " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover - else: - # Priority 3: Omit scope parameter - return None - - -def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: - """ - Generate ordered list of (url, type) tuples for discovery attempts. - - Args: - auth_server_url: URL for the OAuth Authorization Metadata URL if found, otherwise None - server_url: URL for the MCP server, used as a fallback if auth_server_url is None - """ - - if not auth_server_url: - # Legacy path using the 2025-03-26 spec: - # link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization - parsed = urlparse(server_url) - return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"] - - urls: list[str] = [] - parsed = urlparse(auth_server_url) - base_url = f"{parsed.scheme}://{parsed.netloc}" - - # RFC 8414: Path-aware OAuth discovery - if parsed.path and parsed.path != "/": - oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}" - urls.append(urljoin(base_url, oauth_path)) - - # RFC 8414 section 5: Path-aware OIDC discovery - # See https://www.rfc-editor.org/rfc/rfc8414.html#section-5 - oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}" - urls.append(urljoin(base_url, oidc_path)) - - # https://openid.net/specs/openid-connect-discovery-1_0.html - oidc_path = f"{parsed.path.rstrip('/')}/.well-known/openid-configuration" - urls.append(urljoin(base_url, oidc_path)) - return urls - - # OAuth root - urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server")) - - # OIDC 1.0 fallback (appends to full URL per OIDC spec) - # https://openid.net/specs/openid-connect-discovery-1_0.html - urls.append(urljoin(base_url, "/.well-known/openid-configuration")) - - return urls - - -async def handle_protected_resource_response( - response: Response, -) -> ProtectedResourceMetadata | None: - """ - Handle protected resource metadata discovery response. - - Per SEP-985, supports fallback when discovery fails at one URL. - - Returns: - True if metadata was successfully discovered, False if we should try next URL - """ - if response.status_code == 200: - try: - content = await response.aread() - metadata = ProtectedResourceMetadata.model_validate_json(content) - return metadata - - except ValidationError: # pragma: no cover - # Invalid metadata - try next URL - return None - else: - # Not found - try next URL in fallback chain - return None - - -async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuthMetadata | None]: - if response.status_code == 200: - try: - content = await response.aread() - asm = OAuthMetadata.model_validate_json(content) - return True, asm - except ValidationError: # pragma: no cover - return True, None - elif response.status_code < 400 or response.status_code >= 500: - return False, None # Non-4XX error, stop trying - return True, None - - -def create_oauth_metadata_request(url: str) -> Request: - return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) - - -def create_client_registration_request( - auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str -) -> Request: - """Build registration request or skip if already registered.""" - - if auth_server_metadata and auth_server_metadata.registration_endpoint: - registration_url = str(auth_server_metadata.registration_endpoint) - else: - registration_url = urljoin(auth_base_url, "/register") - - registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True) - - return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}) - - -async def handle_registration_response(response: Response) -> OAuthClientInformationFull: - """Handle registration response.""" - if response.status_code not in (200, 201): - await response.aread() - raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}") - - try: - content = await response.aread() - client_info = OAuthClientInformationFull.model_validate_json(content) - return client_info - # self.context.client_info = client_info - # await self.context.storage.set_client_info(client_info) - except ValidationError as e: # pragma: no cover - raise OAuthRegistrationError(f"Invalid registration response: {e}") - - -def is_valid_client_metadata_url(url: str | None) -> bool: - """Validate that a URL is suitable for use as a client_id (CIMD). - - The URL must be HTTPS with a non-root pathname. - - Args: - url: The URL to validate - - Returns: - True if the URL is a valid HTTPS URL with a non-root pathname - """ - if not url: - return False - try: - parsed = urlparse(url) - return parsed.scheme == "https" and parsed.path not in ("", "/") - except Exception: - return False - - -def should_use_client_metadata_url( - oauth_metadata: OAuthMetadata | None, - client_metadata_url: str | None, -) -> bool: - """Determine if URL-based client ID (CIMD) should be used instead of DCR. - - URL-based client IDs should be used when: - 1. The server advertises client_id_metadata_document_supported=true - 2. The client has a valid client_metadata_url configured - - Args: - oauth_metadata: OAuth authorization server metadata - client_metadata_url: URL-based client ID (already validated) - - Returns: - True if CIMD should be used, False if DCR should be used - """ - if not client_metadata_url: - return False - - if not oauth_metadata: - return False - - return oauth_metadata.client_id_metadata_document_supported is True - - -def create_client_info_from_metadata_url( - client_metadata_url: str, redirect_uris: list[AnyUrl] | None = None -) -> OAuthClientInformationFull: - """Create client information using a URL-based client ID (CIMD). - - When using URL-based client IDs, the URL itself becomes the client_id - and no client_secret is used (token_endpoint_auth_method="none"). - - Args: - client_metadata_url: The URL to use as the client_id - redirect_uris: The redirect URIs from the client metadata (passed through for - compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata) - - Returns: - OAuthClientInformationFull with the URL as client_id - """ - return OAuthClientInformationFull( - client_id=client_metadata_url, - token_endpoint_auth_method="none", - redirect_uris=redirect_uris, - ) - - -async def handle_token_response_scopes( - response: Response, -) -> OAuthToken: - """Parse and validate token response with optional scope validation. - - Parses token response JSON. Callers should check response.status_code before calling. - - Args: - response: HTTP response from token endpoint (status already checked by caller) - - Returns: - Validated OAuthToken model - - Raises: - OAuthTokenError: If response JSON is invalid - """ - try: - content = await response.aread() - token_response = OAuthToken.model_validate_json(content) - return token_response - except ValidationError as e: # pragma: no cover - raise OAuthTokenError(f"Invalid token response: {e}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/__init__.py deleted file mode 100644 index b6579b1..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Experimental client features. - -WARNING: These APIs are experimental and may change without notice. -""" - -from mcp.client.experimental.tasks import ExperimentalClientFeatures - -__all__ = ["ExperimentalClientFeatures"] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/task_handlers.py b/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/task_handlers.py deleted file mode 100644 index a475086..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/task_handlers.py +++ /dev/null @@ -1,290 +0,0 @@ -""" -Experimental task handler protocols for server -> client requests. - -This module provides Protocol types and default handlers for when servers -send task-related requests to clients (the reverse of normal client -> server flow). - -WARNING: These APIs are experimental and may change without notice. - -Use cases: -- Server sends task-augmented sampling/elicitation request to client -- Client creates a local task, spawns background work, returns CreateTaskResult -- Server polls client's task status via tasks/get, tasks/result, etc. -""" - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Protocol - -from pydantic import TypeAdapter - -import mcp.types as types -from mcp.shared.context import RequestContext -from mcp.shared.session import RequestResponder - -if TYPE_CHECKING: - from mcp.client.session import ClientSession - - -class GetTaskHandlerFnT(Protocol): - """Handler for tasks/get requests from server. - - WARNING: This is experimental and may change without notice. - """ - - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.GetTaskRequestParams, - ) -> types.GetTaskResult | types.ErrorData: ... # pragma: no branch - - -class GetTaskResultHandlerFnT(Protocol): - """Handler for tasks/result requests from server. - - WARNING: This is experimental and may change without notice. - """ - - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.GetTaskPayloadRequestParams, - ) -> types.GetTaskPayloadResult | types.ErrorData: ... # pragma: no branch - - -class ListTasksHandlerFnT(Protocol): - """Handler for tasks/list requests from server. - - WARNING: This is experimental and may change without notice. - """ - - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.PaginatedRequestParams | None, - ) -> types.ListTasksResult | types.ErrorData: ... # pragma: no branch - - -class CancelTaskHandlerFnT(Protocol): - """Handler for tasks/cancel requests from server. - - WARNING: This is experimental and may change without notice. - """ - - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.CancelTaskRequestParams, - ) -> types.CancelTaskResult | types.ErrorData: ... # pragma: no branch - - -class TaskAugmentedSamplingFnT(Protocol): - """Handler for task-augmented sampling/createMessage requests from server. - - When server sends a CreateMessageRequest with task field, this callback - is invoked. The callback should create a task, spawn background work, - and return CreateTaskResult immediately. - - WARNING: This is experimental and may change without notice. - """ - - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.CreateMessageRequestParams, - task_metadata: types.TaskMetadata, - ) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch - - -class TaskAugmentedElicitationFnT(Protocol): - """Handler for task-augmented elicitation/create requests from server. - - When server sends an ElicitRequest with task field, this callback - is invoked. The callback should create a task, spawn background work, - and return CreateTaskResult immediately. - - WARNING: This is experimental and may change without notice. - """ - - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.ElicitRequestParams, - task_metadata: types.TaskMetadata, - ) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch - - -async def default_get_task_handler( - context: RequestContext["ClientSession", Any], - params: types.GetTaskRequestParams, -) -> types.GetTaskResult | types.ErrorData: - return types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="tasks/get not supported", - ) - - -async def default_get_task_result_handler( - context: RequestContext["ClientSession", Any], - params: types.GetTaskPayloadRequestParams, -) -> types.GetTaskPayloadResult | types.ErrorData: - return types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="tasks/result not supported", - ) - - -async def default_list_tasks_handler( - context: RequestContext["ClientSession", Any], - params: types.PaginatedRequestParams | None, -) -> types.ListTasksResult | types.ErrorData: - return types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="tasks/list not supported", - ) - - -async def default_cancel_task_handler( - context: RequestContext["ClientSession", Any], - params: types.CancelTaskRequestParams, -) -> types.CancelTaskResult | types.ErrorData: - return types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="tasks/cancel not supported", - ) - - -async def default_task_augmented_sampling( - context: RequestContext["ClientSession", Any], - params: types.CreateMessageRequestParams, - task_metadata: types.TaskMetadata, -) -> types.CreateTaskResult | types.ErrorData: - return types.ErrorData( - code=types.INVALID_REQUEST, - message="Task-augmented sampling not supported", - ) - - -async def default_task_augmented_elicitation( - context: RequestContext["ClientSession", Any], - params: types.ElicitRequestParams, - task_metadata: types.TaskMetadata, -) -> types.CreateTaskResult | types.ErrorData: - return types.ErrorData( - code=types.INVALID_REQUEST, - message="Task-augmented elicitation not supported", - ) - - -@dataclass -class ExperimentalTaskHandlers: - """Container for experimental task handlers. - - Groups all task-related handlers that handle server -> client requests. - This includes both pure task requests (get, list, cancel, result) and - task-augmented request handlers (sampling, elicitation with task field). - - WARNING: These APIs are experimental and may change without notice. - - Example: - handlers = ExperimentalTaskHandlers( - get_task=my_get_task_handler, - list_tasks=my_list_tasks_handler, - ) - session = ClientSession(..., experimental_task_handlers=handlers) - """ - - # Pure task request handlers - get_task: GetTaskHandlerFnT = field(default=default_get_task_handler) - get_task_result: GetTaskResultHandlerFnT = field(default=default_get_task_result_handler) - list_tasks: ListTasksHandlerFnT = field(default=default_list_tasks_handler) - cancel_task: CancelTaskHandlerFnT = field(default=default_cancel_task_handler) - - # Task-augmented request handlers - augmented_sampling: TaskAugmentedSamplingFnT = field(default=default_task_augmented_sampling) - augmented_elicitation: TaskAugmentedElicitationFnT = field(default=default_task_augmented_elicitation) - - def build_capability(self) -> types.ClientTasksCapability | None: - """Build ClientTasksCapability from the configured handlers. - - Returns a capability object that reflects which handlers are configured - (i.e., not using the default "not supported" handlers). - - Returns: - ClientTasksCapability if any handlers are provided, None otherwise - """ - has_list = self.list_tasks is not default_list_tasks_handler - has_cancel = self.cancel_task is not default_cancel_task_handler - has_sampling = self.augmented_sampling is not default_task_augmented_sampling - has_elicitation = self.augmented_elicitation is not default_task_augmented_elicitation - - # If no handlers are provided, return None - if not any([has_list, has_cancel, has_sampling, has_elicitation]): - return None - - # Build requests capability if any request handlers are provided - requests_capability: types.ClientTasksRequestsCapability | None = None - if has_sampling or has_elicitation: - requests_capability = types.ClientTasksRequestsCapability( - sampling=types.TasksSamplingCapability(createMessage=types.TasksCreateMessageCapability()) - if has_sampling - else None, - elicitation=types.TasksElicitationCapability(create=types.TasksCreateElicitationCapability()) - if has_elicitation - else None, - ) - - return types.ClientTasksCapability( - list=types.TasksListCapability() if has_list else None, - cancel=types.TasksCancelCapability() if has_cancel else None, - requests=requests_capability, - ) - - @staticmethod - def handles_request(request: types.ServerRequest) -> bool: - """Check if this handler handles the given request type.""" - return isinstance( - request.root, - types.GetTaskRequest | types.GetTaskPayloadRequest | types.ListTasksRequest | types.CancelTaskRequest, - ) - - async def handle_request( - self, - ctx: RequestContext["ClientSession", Any], - responder: RequestResponder[types.ServerRequest, types.ClientResult], - ) -> None: - """Handle a task-related request from the server. - - Call handles_request() first to check if this handler can handle the request. - """ - client_response_type: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter( - types.ClientResult | types.ErrorData - ) - - match responder.request.root: - case types.GetTaskRequest(params=params): - response = await self.get_task(ctx, params) - client_response = client_response_type.validate_python(response) - await responder.respond(client_response) - - case types.GetTaskPayloadRequest(params=params): - response = await self.get_task_result(ctx, params) - client_response = client_response_type.validate_python(response) - await responder.respond(client_response) - - case types.ListTasksRequest(params=params): - response = await self.list_tasks(ctx, params) - client_response = client_response_type.validate_python(response) - await responder.respond(client_response) - - case types.CancelTaskRequest(params=params): - response = await self.cancel_task(ctx, params) - client_response = client_response_type.validate_python(response) - await responder.respond(client_response) - - case _: # pragma: no cover - raise ValueError(f"Unhandled request type: {type(responder.request.root)}") - - -# Backwards compatibility aliases -default_task_augmented_sampling_callback = default_task_augmented_sampling -default_task_augmented_elicitation_callback = default_task_augmented_elicitation diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/tasks.py b/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/tasks.py deleted file mode 100644 index ce9c387..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/experimental/tasks.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -Experimental client-side task support. - -This module provides client methods for interacting with MCP tasks. - -WARNING: These APIs are experimental and may change without notice. - -Example: - # Call a tool as a task - result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) - task_id = result.task.taskId - - # Get task status - status = await session.experimental.get_task(task_id) - - # Get task result when complete - if status.status == "completed": - result = await session.experimental.get_task_result(task_id, CallToolResult) - - # List all tasks - tasks = await session.experimental.list_tasks() - - # Cancel a task - await session.experimental.cancel_task(task_id) -""" - -from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, TypeVar - -import mcp.types as types -from mcp.shared.experimental.tasks.polling import poll_until_terminal - -if TYPE_CHECKING: - from mcp.client.session import ClientSession - -ResultT = TypeVar("ResultT", bound=types.Result) - - -class ExperimentalClientFeatures: - """ - Experimental client features for tasks and other experimental APIs. - - WARNING: These APIs are experimental and may change without notice. - - Access via session.experimental: - status = await session.experimental.get_task(task_id) - """ - - def __init__(self, session: "ClientSession") -> None: - self._session = session - - async def call_tool_as_task( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - ttl: int = 60000, - meta: dict[str, Any] | None = None, - ) -> types.CreateTaskResult: - """Call a tool as a task, returning a CreateTaskResult for polling. - - This is a convenience method for calling tools that support task execution. - The server will return a task reference instead of the immediate result, - which can then be polled via `get_task()` and retrieved via `get_task_result()`. - - Args: - name: The tool name - arguments: Tool arguments - ttl: Task time-to-live in milliseconds (default: 60000 = 1 minute) - meta: Optional metadata to include in the request - - Returns: - CreateTaskResult containing the task reference - - Example: - # Create task - result = await session.experimental.call_tool_as_task( - "long_running_tool", {"input": "data"} - ) - task_id = result.task.taskId - - # Poll for completion - while True: - status = await session.experimental.get_task(task_id) - if status.status == "completed": - break - await asyncio.sleep(0.5) - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) - """ - _meta: types.RequestParams.Meta | None = None - if meta is not None: - _meta = types.RequestParams.Meta(**meta) - - return await self._session.send_request( - types.ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams( - name=name, - arguments=arguments, - task=types.TaskMetadata(ttl=ttl), - _meta=_meta, - ), - ) - ), - types.CreateTaskResult, - ) - - async def get_task(self, task_id: str) -> types.GetTaskResult: - """ - Get the current status of a task. - - Args: - task_id: The task identifier - - Returns: - GetTaskResult containing the task status and metadata - """ - return await self._session.send_request( - types.ClientRequest( - types.GetTaskRequest( - params=types.GetTaskRequestParams(taskId=task_id), - ) - ), - types.GetTaskResult, - ) - - async def get_task_result( - self, - task_id: str, - result_type: type[ResultT], - ) -> ResultT: - """ - Get the result of a completed task. - - The result type depends on the original request type: - - tools/call tasks return CallToolResult - - Other request types return their corresponding result type - - Args: - task_id: The task identifier - result_type: The expected result type (e.g., CallToolResult) - - Returns: - The task result, validated against result_type - """ - return await self._session.send_request( - types.ClientRequest( - types.GetTaskPayloadRequest( - params=types.GetTaskPayloadRequestParams(taskId=task_id), - ) - ), - result_type, - ) - - async def list_tasks( - self, - cursor: str | None = None, - ) -> types.ListTasksResult: - """ - List all tasks. - - Args: - cursor: Optional pagination cursor - - Returns: - ListTasksResult containing tasks and optional next cursor - """ - params = types.PaginatedRequestParams(cursor=cursor) if cursor else None - return await self._session.send_request( - types.ClientRequest( - types.ListTasksRequest(params=params), - ), - types.ListTasksResult, - ) - - async def cancel_task(self, task_id: str) -> types.CancelTaskResult: - """ - Cancel a running task. - - Args: - task_id: The task identifier - - Returns: - CancelTaskResult with the updated task state - """ - return await self._session.send_request( - types.ClientRequest( - types.CancelTaskRequest( - params=types.CancelTaskRequestParams(taskId=task_id), - ) - ), - types.CancelTaskResult, - ) - - async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: - """ - Poll a task until it reaches a terminal status. - - Yields GetTaskResult for each poll, allowing the caller to react to - status changes (e.g., handle input_required). Exits when task reaches - a terminal status (completed, failed, cancelled). - - Respects the pollInterval hint from the server. - - Args: - task_id: The task identifier - - Yields: - GetTaskResult for each poll - - Example: - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - if status.status == "input_required": - # Handle elicitation request via tasks/result - pass - - # Task is now terminal, get the result - result = await session.experimental.get_task_result(task_id, CallToolResult) - """ - async for status in poll_until_terminal(self.get_task, task_id): - yield status diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/session.py b/src/agent_workflow/mcp/sdk/src/mcp/client/session.py deleted file mode 100644 index 86f2676..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/session.py +++ /dev/null @@ -1,633 +0,0 @@ -import logging -import warnings -from datetime import timedelta -from typing import Any, Protocol, overload - -import anyio.lowlevel -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl, TypeAdapter -from typing_extensions import deprecated - -import mcp.types as types -from mcp.client.experimental import ExperimentalClientFeatures -from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers -from mcp.shared.context import RequestContext -from mcp.shared.message import SessionMessage -from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS - -DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") - -# Type checkers only surface `@deprecated` messages given as string literals, so the same text is -# repeated inline at each deprecated entry point (here, server/session.py, server/lowlevel/server.py). -# Keep those copies, the filterwarnings marks in the tasks test suites, and the pytest.warns match in -# tests/experimental/tasks/test_deprecations.py prefix-aligned when rewording. -_EXPERIMENTAL_TASKS_DEPRECATION = ( - "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" - " from the MCP specification and are expected to return as a separate MCP extension." -) - -logger = logging.getLogger("client") - - -class SamplingFnT(Protocol): - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.CreateMessageRequestParams, - ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch - - -class ElicitationFnT(Protocol): - async def __call__( - self, - context: RequestContext["ClientSession", Any], - params: types.ElicitRequestParams, - ) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch - - -class ListRootsFnT(Protocol): - async def __call__( - self, context: RequestContext["ClientSession", Any] - ) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch - - -class LoggingFnT(Protocol): - async def __call__( - self, - params: types.LoggingMessageNotificationParams, - ) -> None: ... # pragma: no branch - - -class MessageHandlerFnT(Protocol): - async def __call__( - self, - message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, - ) -> None: ... # pragma: no branch - - -async def _default_message_handler( - message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, -) -> None: - await anyio.lowlevel.checkpoint() - - -async def _default_sampling_callback( - context: RequestContext["ClientSession", Any], - params: types.CreateMessageRequestParams, -) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: - return types.ErrorData( - code=types.INVALID_REQUEST, - message="Sampling not supported", - ) - - -async def _default_elicitation_callback( - context: RequestContext["ClientSession", Any], - params: types.ElicitRequestParams, -) -> types.ElicitResult | types.ErrorData: - return types.ErrorData( # pragma: no cover - code=types.INVALID_REQUEST, - message="Elicitation not supported", - ) - - -async def _default_list_roots_callback( - context: RequestContext["ClientSession", Any], -) -> types.ListRootsResult | types.ErrorData: - return types.ErrorData( - code=types.INVALID_REQUEST, - message="List roots not supported", - ) - - -async def _default_logging_callback( - params: types.LoggingMessageNotificationParams, -) -> None: - pass - - -ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) - - -class ClientSession( - BaseSession[ - types.ClientRequest, - types.ClientNotification, - types.ClientResult, - types.ServerRequest, - types.ServerNotification, - ] -): - def __init__( - self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], - read_timeout_seconds: timedelta | None = None, - sampling_callback: SamplingFnT | None = None, - elicitation_callback: ElicitationFnT | None = None, - list_roots_callback: ListRootsFnT | None = None, - logging_callback: LoggingFnT | None = None, - message_handler: MessageHandlerFnT | None = None, - client_info: types.Implementation | None = None, - *, - sampling_capabilities: types.SamplingCapability | None = None, - experimental_task_handlers: ExperimentalTaskHandlers | None = None, - ) -> None: - super().__init__( - read_stream, - write_stream, - types.ServerRequest, - types.ServerNotification, - read_timeout_seconds=read_timeout_seconds, - ) - self._client_info = client_info or DEFAULT_CLIENT_INFO - self._sampling_callback = sampling_callback or _default_sampling_callback - self._sampling_capabilities = sampling_capabilities - self._elicitation_callback = elicitation_callback or _default_elicitation_callback - self._list_roots_callback = list_roots_callback or _default_list_roots_callback - self._logging_callback = logging_callback or _default_logging_callback - self._message_handler = message_handler or _default_message_handler - self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} - self._server_capabilities: types.ServerCapabilities | None = None - self._experimental_features: ExperimentalClientFeatures | None = None - - # Experimental: Task handlers (use defaults if not provided) - if experimental_task_handlers is not None: - warnings.warn(_EXPERIMENTAL_TASKS_DEPRECATION, DeprecationWarning, stacklevel=2) - self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers() - - async def initialize(self) -> types.InitializeResult: - sampling = ( - (self._sampling_capabilities or types.SamplingCapability()) - if self._sampling_callback is not _default_sampling_callback - else None - ) - elicitation = ( - types.ElicitationCapability( - form=types.FormElicitationCapability(), - url=types.UrlElicitationCapability(), - ) - if self._elicitation_callback is not _default_elicitation_callback - else None - ) - roots = ( - # TODO: Should this be based on whether we - # _will_ send notifications, or only whether - # they're supported? - types.RootsCapability(listChanged=True) - if self._list_roots_callback is not _default_list_roots_callback - else None - ) - - result = await self.send_request( - types.ClientRequest( - types.InitializeRequest( - params=types.InitializeRequestParams( - protocolVersion=types.LATEST_PROTOCOL_VERSION, - capabilities=types.ClientCapabilities( - sampling=sampling, - elicitation=elicitation, - experimental=None, - roots=roots, - tasks=self._task_handlers.build_capability(), - ), - clientInfo=self._client_info, - ), - ) - ), - types.InitializeResult, - ) - - if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS: - raise RuntimeError(f"Unsupported protocol version from the server: {result.protocolVersion}") - - self._server_capabilities = result.capabilities - - await self.send_notification(types.ClientNotification(types.InitializedNotification())) - - return result - - def get_server_capabilities(self) -> types.ServerCapabilities | None: - """Return the server capabilities received during initialization. - - Returns None if the session has not been initialized yet. - """ - return self._server_capabilities - - @property - @deprecated( - "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" - " from the MCP specification and are expected to return as a separate MCP extension." - ) - def experimental(self) -> ExperimentalClientFeatures: - """Experimental APIs for tasks and other features. - - Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks - (SEP-1686) were removed from the MCP specification and are expected to - return as a separate MCP extension. - - Example: - status = await session.experimental.get_task(task_id) - result = await session.experimental.get_task_result(task_id, CallToolResult) - """ - if self._experimental_features is None: - self._experimental_features = ExperimentalClientFeatures(self) - return self._experimental_features - - async def send_ping(self) -> types.EmptyResult: - """Send a ping request.""" - return await self.send_request( - types.ClientRequest(types.PingRequest()), - types.EmptyResult, - ) - - async def send_progress_notification( - self, - progress_token: str | int, - progress: float, - total: float | None = None, - message: str | None = None, - ) -> None: - """Send a progress notification.""" - await self.send_notification( - types.ClientNotification( - types.ProgressNotification( - params=types.ProgressNotificationParams( - progressToken=progress_token, - progress=progress, - total=total, - message=message, - ), - ), - ) - ) - - async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult: - """Send a logging/setLevel request.""" - return await self.send_request( # pragma: no cover - types.ClientRequest( - types.SetLevelRequest( - params=types.SetLevelRequestParams(level=level), - ) - ), - types.EmptyResult, - ) - - @overload - @deprecated("Use list_resources(params=PaginatedRequestParams(...)) instead") - async def list_resources(self, cursor: str | None) -> types.ListResourcesResult: ... - - @overload - async def list_resources(self, *, params: types.PaginatedRequestParams | None) -> types.ListResourcesResult: ... - - @overload - async def list_resources(self) -> types.ListResourcesResult: ... - - async def list_resources( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListResourcesResult: - """Send a resources/list request. - - Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) - params: Full pagination parameters including cursor and any future fields - """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - - return await self.send_request( - types.ClientRequest(types.ListResourcesRequest(params=request_params)), - types.ListResourcesResult, - ) - - @overload - @deprecated("Use list_resource_templates(params=PaginatedRequestParams(...)) instead") - async def list_resource_templates(self, cursor: str | None) -> types.ListResourceTemplatesResult: ... - - @overload - async def list_resource_templates( - self, *, params: types.PaginatedRequestParams | None - ) -> types.ListResourceTemplatesResult: ... - - @overload - async def list_resource_templates(self) -> types.ListResourceTemplatesResult: ... - - async def list_resource_templates( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListResourceTemplatesResult: - """Send a resources/templates/list request. - - Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) - params: Full pagination parameters including cursor and any future fields - """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - - return await self.send_request( - types.ClientRequest(types.ListResourceTemplatesRequest(params=request_params)), - types.ListResourceTemplatesResult, - ) - - async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: - """Send a resources/read request.""" - return await self.send_request( - types.ClientRequest( - types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=uri), - ) - ), - types.ReadResourceResult, - ) - - async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: - """Send a resources/subscribe request.""" - return await self.send_request( # pragma: no cover - types.ClientRequest( - types.SubscribeRequest( - params=types.SubscribeRequestParams(uri=uri), - ) - ), - types.EmptyResult, - ) - - async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: - """Send a resources/unsubscribe request.""" - return await self.send_request( # pragma: no cover - types.ClientRequest( - types.UnsubscribeRequest( - params=types.UnsubscribeRequestParams(uri=uri), - ) - ), - types.EmptyResult, - ) - - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - read_timeout_seconds: timedelta | None = None, - progress_callback: ProgressFnT | None = None, - *, - meta: dict[str, Any] | None = None, - ) -> types.CallToolResult: - """Send a tools/call request with optional progress callback support.""" - - _meta: types.RequestParams.Meta | None = None - if meta is not None: - _meta = types.RequestParams.Meta(**meta) - - result = await self.send_request( - types.ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=_meta), - ) - ), - types.CallToolResult, - request_read_timeout_seconds=read_timeout_seconds, - progress_callback=progress_callback, - ) - - if not result.isError: - await self._validate_tool_result(name, result) - - return result - - async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None: - """Validate the structured content of a tool result against its output schema.""" - if name not in self._tool_output_schemas: - # refresh output schema cache - await self.list_tools() - - output_schema = None - if name in self._tool_output_schemas: - output_schema = self._tool_output_schemas.get(name) - else: - logger.warning(f"Tool {name} not listed by server, cannot validate any structured content") - - if output_schema is not None: - from jsonschema import SchemaError, ValidationError, validate - - if result.structuredContent is None: - raise RuntimeError( - f"Tool {name} has an output schema but did not return structured content" - ) # pragma: no cover - try: - validate(result.structuredContent, output_schema) - except ValidationError as e: - raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover - except SchemaError as e: # pragma: no cover - raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover - - @overload - @deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead") - async def list_prompts(self, cursor: str | None) -> types.ListPromptsResult: ... - - @overload - async def list_prompts(self, *, params: types.PaginatedRequestParams | None) -> types.ListPromptsResult: ... - - @overload - async def list_prompts(self) -> types.ListPromptsResult: ... - - async def list_prompts( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListPromptsResult: - """Send a prompts/list request. - - Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) - params: Full pagination parameters including cursor and any future fields - """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - - return await self.send_request( - types.ClientRequest(types.ListPromptsRequest(params=request_params)), - types.ListPromptsResult, - ) - - async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: - """Send a prompts/get request.""" - return await self.send_request( - types.ClientRequest( - types.GetPromptRequest( - params=types.GetPromptRequestParams(name=name, arguments=arguments), - ) - ), - types.GetPromptResult, - ) - - async def complete( - self, - ref: types.ResourceTemplateReference | types.PromptReference, - argument: dict[str, str], - context_arguments: dict[str, str] | None = None, - ) -> types.CompleteResult: - """Send a completion/complete request.""" - context = None - if context_arguments is not None: - context = types.CompletionContext(arguments=context_arguments) - - return await self.send_request( - types.ClientRequest( - types.CompleteRequest( - params=types.CompleteRequestParams( - ref=ref, - argument=types.CompletionArgument(**argument), - context=context, - ), - ) - ), - types.CompleteResult, - ) - - @overload - @deprecated("Use list_tools(params=PaginatedRequestParams(...)) instead") - async def list_tools(self, cursor: str | None) -> types.ListToolsResult: ... - - @overload - async def list_tools(self, *, params: types.PaginatedRequestParams | None) -> types.ListToolsResult: ... - - @overload - async def list_tools(self) -> types.ListToolsResult: ... - - async def list_tools( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListToolsResult: - """Send a tools/list request. - - Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) - params: Full pagination parameters including cursor and any future fields - """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - - result = await self.send_request( - types.ClientRequest(types.ListToolsRequest(params=request_params)), - types.ListToolsResult, - ) - - # Cache tool output schemas for future validation - # Note: don't clear the cache, as we may be using a cursor - for tool in result.tools: - self._tool_output_schemas[tool.name] = tool.outputSchema - - return result - - async def send_roots_list_changed(self) -> None: # pragma: no cover - """Send a roots/list_changed notification.""" - await self.send_notification(types.ClientNotification(types.RootsListChangedNotification())) - - async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None: - ctx = RequestContext[ClientSession, Any]( - request_id=responder.request_id, - meta=responder.request_meta, - session=self, - lifespan_context=None, - ) - - # Delegate to experimental task handler if applicable - if self._task_handlers.handles_request(responder.request): - with responder: - await self._task_handlers.handle_request(ctx, responder) - return None - - # Core request handling - match responder.request.root: - case types.CreateMessageRequest(params=params): - with responder: - # Check if this is a task-augmented request - if params.task is not None: - response = await self._task_handlers.augmented_sampling(ctx, params, params.task) - else: - response = await self._sampling_callback(ctx, params) - client_response = ClientResponse.validate_python(response) - await responder.respond(client_response) - - case types.ElicitRequest(params=params): - with responder: - # Check if this is a task-augmented request - if params.task is not None: - response = await self._task_handlers.augmented_elicitation(ctx, params, params.task) - else: - response = await self._elicitation_callback(ctx, params) - client_response = ClientResponse.validate_python(response) - await responder.respond(client_response) - - case types.ListRootsRequest(): - with responder: - response = await self._list_roots_callback(ctx) - client_response = ClientResponse.validate_python(response) - await responder.respond(client_response) - - case types.PingRequest(): # pragma: no cover - with responder: - return await responder.respond(types.ClientResult(root=types.EmptyResult())) - - case _: # pragma: no cover - pass # Task requests handled above by _task_handlers - - return None - - async def _handle_incoming( - self, - req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, - ) -> None: - """Handle incoming messages by forwarding to the message handler.""" - await self._message_handler(req) - - async def _received_notification(self, notification: types.ServerNotification) -> None: - """Handle notifications from the server.""" - # Process specific notification types - match notification.root: - case types.LoggingMessageNotification(params=params): - await self._logging_callback(params) - case types.ElicitCompleteNotification(params=params): - # Handle elicitation completion notification - # Clients MAY use this to retry requests or update UI - # The notification contains the elicitationId of the completed elicitation - pass - case _: - pass diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/session_group.py b/src/agent_workflow/mcp/sdk/src/mcp/client/session_group.py deleted file mode 100644 index f82677d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/session_group.py +++ /dev/null @@ -1,447 +0,0 @@ -""" -SessionGroup concurrently manages multiple MCP session connections. - -Tools, resources, and prompts are aggregated across servers. Servers may -be connected to or disconnected from at any point after initialization. - -This abstractions can handle naming collisions using a custom user-provided -hook. -""" - -import contextlib -import logging -from collections.abc import Callable -from dataclasses import dataclass -from datetime import timedelta -from types import TracebackType -from typing import Any, TypeAlias, overload - -import anyio -import httpx -from pydantic import BaseModel -from typing_extensions import Self, deprecated - -import mcp -from mcp import types -from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT -from mcp.client.sse import sse_client -from mcp.client.stdio import StdioServerParameters -from mcp.client.streamable_http import streamable_http_client -from mcp.shared._httpx_utils import create_mcp_http_client -from mcp.shared.exceptions import McpError -from mcp.shared.session import ProgressFnT - - -class SseServerParameters(BaseModel): - """Parameters for intializing a sse_client.""" - - # The endpoint URL. - url: str - - # Optional headers to include in requests. - headers: dict[str, Any] | None = None - - # HTTP timeout for regular operations. - timeout: float = 5 - - # Timeout for SSE read operations. - sse_read_timeout: float = 60 * 5 - - -class StreamableHttpParameters(BaseModel): - """Parameters for intializing a streamable_http_client.""" - - # The endpoint URL. - url: str - - # Optional headers to include in requests. - headers: dict[str, Any] | None = None - - # HTTP timeout for regular operations. - timeout: timedelta = timedelta(seconds=30) - - # Timeout for SSE read operations. - sse_read_timeout: timedelta = timedelta(seconds=60 * 5) - - # Close the client session when the transport closes. - terminate_on_close: bool = True - - -ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters - - -# Use dataclass instead of pydantic BaseModel -# because pydantic BaseModel cannot handle Protocol fields. -@dataclass -class ClientSessionParameters: - """Parameters for establishing a client session to an MCP server.""" - - read_timeout_seconds: timedelta | None = None - sampling_callback: SamplingFnT | None = None - elicitation_callback: ElicitationFnT | None = None - list_roots_callback: ListRootsFnT | None = None - logging_callback: LoggingFnT | None = None - message_handler: MessageHandlerFnT | None = None - client_info: types.Implementation | None = None - - -class ClientSessionGroup: - """Client for managing connections to multiple MCP servers. - - This class is responsible for encapsulating management of server connections. - It aggregates tools, resources, and prompts from all connected servers. - - For auxiliary handlers, such as resource subscription, this is delegated to - the client and can be accessed via the session. - - Example Usage: - name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" - async with ClientSessionGroup(component_name_hook=name_fn) as group: - for server_param in server_params: - await group.connect_to_server(server_param) - ... - - """ - - class _ComponentNames(BaseModel): - """Used for reverse index to find components.""" - - prompts: set[str] = set() - resources: set[str] = set() - tools: set[str] = set() - - # Standard MCP components. - _prompts: dict[str, types.Prompt] - _resources: dict[str, types.Resource] - _tools: dict[str, types.Tool] - - # Client-server connection management. - _sessions: dict[mcp.ClientSession, _ComponentNames] - _tool_to_session: dict[str, mcp.ClientSession] - _exit_stack: contextlib.AsyncExitStack - _session_exit_stacks: dict[mcp.ClientSession, contextlib.AsyncExitStack] - - # Optional fn consuming (component_name, serverInfo) for custom names. - # This is provide a means to mitigate naming conflicts across servers. - # Example: (tool_name, serverInfo) => "{result.serverInfo.name}.{tool_name}" - _ComponentNameHook: TypeAlias = Callable[[str, types.Implementation], str] - _component_name_hook: _ComponentNameHook | None - - def __init__( - self, - exit_stack: contextlib.AsyncExitStack | None = None, - component_name_hook: _ComponentNameHook | None = None, - ) -> None: - """Initializes the MCP client.""" - - self._tools = {} - self._resources = {} - self._prompts = {} - - self._sessions = {} - self._tool_to_session = {} - if exit_stack is None: - self._exit_stack = contextlib.AsyncExitStack() - self._owns_exit_stack = True - else: - self._exit_stack = exit_stack - self._owns_exit_stack = False - self._session_exit_stacks = {} - self._component_name_hook = component_name_hook - - async def __aenter__(self) -> Self: # pragma: no cover - # Enter the exit stack only if we created it ourselves - if self._owns_exit_stack: - await self._exit_stack.__aenter__() - return self - - async def __aexit__( - self, - _exc_type: type[BaseException] | None, - _exc_val: BaseException | None, - _exc_tb: TracebackType | None, - ) -> bool | None: # pragma: no cover - """Closes session exit stacks and main exit stack upon completion.""" - - # Only close the main exit stack if we created it - if self._owns_exit_stack: - await self._exit_stack.aclose() - - # Concurrently close session stacks. - async with anyio.create_task_group() as tg: - for exit_stack in self._session_exit_stacks.values(): - tg.start_soon(exit_stack.aclose) - - @property - def sessions(self) -> list[mcp.ClientSession]: - """Returns the list of sessions being managed.""" - return list(self._sessions.keys()) # pragma: no cover - - @property - def prompts(self) -> dict[str, types.Prompt]: - """Returns the prompts as a dictionary of names to prompts.""" - return self._prompts - - @property - def resources(self) -> dict[str, types.Resource]: - """Returns the resources as a dictionary of names to resources.""" - return self._resources - - @property - def tools(self) -> dict[str, types.Tool]: - """Returns the tools as a dictionary of names to tools.""" - return self._tools - - @overload - async def call_tool( - self, - name: str, - arguments: dict[str, Any], - read_timeout_seconds: timedelta | None = None, - progress_callback: ProgressFnT | None = None, - *, - meta: dict[str, Any] | None = None, - ) -> types.CallToolResult: ... - - @overload - @deprecated("The 'args' parameter is deprecated. Use 'arguments' instead.") - async def call_tool( - self, - name: str, - *, - args: dict[str, Any], - read_timeout_seconds: timedelta | None = None, - progress_callback: ProgressFnT | None = None, - meta: dict[str, Any] | None = None, - ) -> types.CallToolResult: ... - - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - read_timeout_seconds: timedelta | None = None, - progress_callback: ProgressFnT | None = None, - *, - meta: dict[str, Any] | None = None, - args: dict[str, Any] | None = None, - ) -> types.CallToolResult: - """Executes a tool given its name and arguments.""" - session = self._tool_to_session[name] - session_tool_name = self.tools[name].name - return await session.call_tool( - session_tool_name, - arguments if args is None else args, - read_timeout_seconds=read_timeout_seconds, - progress_callback=progress_callback, - meta=meta, - ) - - async def disconnect_from_server(self, session: mcp.ClientSession) -> None: - """Disconnects from a single MCP server.""" - - session_known_for_components = session in self._sessions - session_known_for_stack = session in self._session_exit_stacks - - if not session_known_for_components and not session_known_for_stack: - raise McpError( - types.ErrorData( - code=types.INVALID_PARAMS, - message="Provided session is not managed or already disconnected.", - ) - ) - - if session_known_for_components: # pragma: no cover - component_names = self._sessions.pop(session) # Pop from _sessions tracking - - # Remove prompts associated with the session. - for name in component_names.prompts: - if name in self._prompts: - del self._prompts[name] - # Remove resources associated with the session. - for name in component_names.resources: - if name in self._resources: - del self._resources[name] - # Remove tools associated with the session. - for name in component_names.tools: - if name in self._tools: - del self._tools[name] - if name in self._tool_to_session: - del self._tool_to_session[name] - - # Clean up the session's resources via its dedicated exit stack - if session_known_for_stack: - session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover - await session_stack_to_close.aclose() # pragma: no cover - - async def connect_with_session( - self, server_info: types.Implementation, session: mcp.ClientSession - ) -> mcp.ClientSession: - """Connects to a single MCP server.""" - await self._aggregate_components(server_info, session) - return session - - async def connect_to_server( - self, - server_params: ServerParameters, - session_params: ClientSessionParameters | None = None, - ) -> mcp.ClientSession: - """Connects to a single MCP server.""" - server_info, session = await self._establish_session(server_params, session_params or ClientSessionParameters()) - return await self.connect_with_session(server_info, session) - - async def _establish_session( - self, - server_params: ServerParameters, - session_params: ClientSessionParameters, - ) -> tuple[types.Implementation, mcp.ClientSession]: - """Establish a client session to an MCP server.""" - - session_stack = contextlib.AsyncExitStack() - try: - # Create read and write streams that facilitate io with the server. - if isinstance(server_params, StdioServerParameters): - client = mcp.stdio_client(server_params) - read, write = await session_stack.enter_async_context(client) - elif isinstance(server_params, SseServerParameters): - client = sse_client( - url=server_params.url, - headers=server_params.headers, - timeout=server_params.timeout, - sse_read_timeout=server_params.sse_read_timeout, - ) - read, write = await session_stack.enter_async_context(client) - else: - httpx_client = create_mcp_http_client( - headers=server_params.headers, - timeout=httpx.Timeout( - server_params.timeout.total_seconds(), - read=server_params.sse_read_timeout.total_seconds(), - ), - ) - await session_stack.enter_async_context(httpx_client) - - client = streamable_http_client( - url=server_params.url, - http_client=httpx_client, - terminate_on_close=server_params.terminate_on_close, - ) - read, write, _ = await session_stack.enter_async_context(client) - - session = await session_stack.enter_async_context( - mcp.ClientSession( - read, - write, - read_timeout_seconds=session_params.read_timeout_seconds, - sampling_callback=session_params.sampling_callback, - elicitation_callback=session_params.elicitation_callback, - list_roots_callback=session_params.list_roots_callback, - logging_callback=session_params.logging_callback, - message_handler=session_params.message_handler, - client_info=session_params.client_info, - ) - ) - - result = await session.initialize() - - # Session successfully initialized. - # Store its stack and register the stack with the main group stack. - self._session_exit_stacks[session] = session_stack - # session_stack itself becomes a resource managed by the - # main _exit_stack. - await self._exit_stack.enter_async_context(session_stack) - - return result.serverInfo, session - except Exception: # pragma: no cover - # If anything during this setup fails, ensure the session-specific - # stack is closed. - await session_stack.aclose() - raise - - async def _aggregate_components(self, server_info: types.Implementation, session: mcp.ClientSession) -> None: - """Aggregates prompts, resources, and tools from a given session.""" - - # Create a reverse index so we can find all prompts, resources, and - # tools belonging to this session. Used for removing components from - # the session group via self.disconnect_from_server. - component_names = self._ComponentNames() - - # Temporary components dicts. We do not want to modify the aggregate - # lists in case of an intermediate failure. - prompts_temp: dict[str, types.Prompt] = {} - resources_temp: dict[str, types.Resource] = {} - tools_temp: dict[str, types.Tool] = {} - tool_to_session_temp: dict[str, mcp.ClientSession] = {} - - # Query the server for its prompts and aggregate to list. - try: - prompts = (await session.list_prompts()).prompts - for prompt in prompts: - name = self._component_name(prompt.name, server_info) - prompts_temp[name] = prompt - component_names.prompts.add(name) - except McpError as err: # pragma: no cover - logging.warning(f"Could not fetch prompts: {err}") - - # Query the server for its resources and aggregate to list. - try: - resources = (await session.list_resources()).resources - for resource in resources: - name = self._component_name(resource.name, server_info) - resources_temp[name] = resource - component_names.resources.add(name) - except McpError as err: # pragma: no cover - logging.warning(f"Could not fetch resources: {err}") - - # Query the server for its tools and aggregate to list. - try: - tools = (await session.list_tools()).tools - for tool in tools: - name = self._component_name(tool.name, server_info) - tools_temp[name] = tool - tool_to_session_temp[name] = session - component_names.tools.add(name) - except McpError as err: # pragma: no cover - logging.warning(f"Could not fetch tools: {err}") - - # Clean up exit stack for session if we couldn't retrieve anything - # from the server. - if not any((prompts_temp, resources_temp, tools_temp)): - del self._session_exit_stacks[session] # pragma: no cover - - # Check for duplicates. - matching_prompts = prompts_temp.keys() & self._prompts.keys() - if matching_prompts: - raise McpError( # pragma: no cover - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_prompts} already exist in group prompts.", - ) - ) - matching_resources = resources_temp.keys() & self._resources.keys() - if matching_resources: - raise McpError( # pragma: no cover - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_resources} already exist in group resources.", - ) - ) - matching_tools = tools_temp.keys() & self._tools.keys() - if matching_tools: - raise McpError( - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_tools} already exist in group tools.", - ) - ) - - # Aggregate components. - self._sessions[session] = component_names - self._prompts.update(prompts_temp) - self._resources.update(resources_temp) - self._tools.update(tools_temp) - self._tool_to_session.update(tool_to_session_temp) - - def _component_name(self, name: str, server_info: types.Implementation) -> str: - if self._component_name_hook: - return self._component_name_hook(name, server_info) - return name diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/sse.py b/src/agent_workflow/mcp/sdk/src/mcp/client/sse.py deleted file mode 100644 index 0d7fa0f..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/sse.py +++ /dev/null @@ -1,163 +0,0 @@ -import logging -from collections.abc import Callable -from contextlib import asynccontextmanager -from typing import Any -from urllib.parse import parse_qs, urljoin, urlparse - -import anyio -import httpx -from anyio.abc import TaskStatus -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx_sse import SSEError, aconnect_sse - -import mcp.types as types -from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client -from mcp.shared.message import SessionMessage - -logger = logging.getLogger(__name__) - - -def remove_request_params(url: str) -> str: - return urljoin(url, urlparse(url).path) - - -def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None: - query_params = parse_qs(urlparse(endpoint_url).query) - return query_params.get("sessionId", [None])[0] or query_params.get("session_id", [None])[0] - - -@asynccontextmanager -async def sse_client( - url: str, - headers: dict[str, Any] | None = None, - timeout: float = 5, - sse_read_timeout: float = 60 * 5, - httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, - auth: httpx.Auth | None = None, - on_session_created: Callable[[str], None] | None = None, -): - """ - Client transport for SSE. - - `sse_read_timeout` determines how long (in seconds) the client will wait for a new - event before disconnecting. All other HTTP operations are controlled by `timeout`. - - Args: - url: The SSE endpoint URL. - headers: Optional headers to include in requests. - timeout: HTTP timeout for regular operations. - sse_read_timeout: Timeout for SSE read operations. - auth: Optional HTTPX authentication handler. - on_session_created: Optional callback invoked with the session ID when received. - """ - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - async with anyio.create_task_group() as tg: - try: - logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}") - async with httpx_client_factory( - headers=headers, auth=auth, timeout=httpx.Timeout(timeout, read=sse_read_timeout) - ) as client: - async with aconnect_sse( - client, - "GET", - url, - ) as event_source: - event_source.response.raise_for_status() - logger.debug("SSE connection established") - - async def sse_reader( - task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED, - ): - try: - async for sse in event_source.aiter_sse(): # pragma: no branch - logger.debug(f"Received SSE event: {sse.event}") - match sse.event: - case "endpoint": - endpoint_url = urljoin(url, sse.data) - logger.debug(f"Received endpoint URL: {endpoint_url}") - - url_parsed = urlparse(url) - endpoint_parsed = urlparse(endpoint_url) - if ( # pragma: no cover - url_parsed.netloc != endpoint_parsed.netloc - or url_parsed.scheme != endpoint_parsed.scheme - ): - error_msg = ( # pragma: no cover - f"Endpoint origin does not match connection origin: {endpoint_url}" - ) - logger.error(error_msg) # pragma: no cover - raise ValueError(error_msg) # pragma: no cover - - if on_session_created: - session_id = _extract_session_id_from_endpoint(endpoint_url) - if session_id: - on_session_created(session_id) - - task_status.started(endpoint_url) - - case "message": - # Skip empty data (keep-alive pings) - if not sse.data: - continue - try: - message = types.JSONRPCMessage.model_validate_json( # noqa: E501 - sse.data - ) - logger.debug(f"Received server message: {message}") - except Exception as exc: # pragma: no cover - logger.exception("Error parsing server message") # pragma: no cover - await read_stream_writer.send(exc) # pragma: no cover - continue # pragma: no cover - - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - case _: # pragma: no cover - logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover - except SSEError as sse_exc: # pragma: no cover - logger.exception("Encountered SSE exception") # pragma: no cover - raise sse_exc # pragma: no cover - except Exception as exc: # pragma: no cover - logger.exception("Error in sse_reader") # pragma: no cover - await read_stream_writer.send(exc) # pragma: no cover - finally: - await read_stream_writer.aclose() - - async def post_writer(endpoint_url: str): - try: - async with write_stream_reader: - async for session_message in write_stream_reader: - logger.debug(f"Sending client message: {session_message}") - response = await client.post( - endpoint_url, - json=session_message.message.model_dump( - by_alias=True, - mode="json", - exclude_none=True, - ), - ) - response.raise_for_status() - logger.debug(f"Client message sent successfully: {response.status_code}") - except Exception: # pragma: no cover - logger.exception("Error in post_writer") # pragma: no cover - finally: - await write_stream.aclose() - - endpoint_url = await tg.start(sse_reader) - logger.debug(f"Starting post writer with endpoint URL: {endpoint_url}") - tg.start_soon(post_writer, endpoint_url) - - try: - yield read_stream, write_stream - finally: - tg.cancel_scope.cancel() - finally: - await read_stream_writer.aclose() - await write_stream.aclose() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/stdio/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/client/stdio/__init__.py deleted file mode 100644 index 0d76bb9..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/stdio/__init__.py +++ /dev/null @@ -1,278 +0,0 @@ -import logging -import os -import sys -from contextlib import asynccontextmanager -from pathlib import Path -from typing import Literal, TextIO - -import anyio -import anyio.lowlevel -from anyio.abc import Process -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from anyio.streams.text import TextReceiveStream -from pydantic import BaseModel, Field - -import mcp.types as types -from mcp.os.posix.utilities import terminate_posix_process_tree -from mcp.os.win32.utilities import ( - FallbackProcess, - create_windows_process, - get_windows_executable_command, - terminate_windows_process_tree, -) -from mcp.shared.message import SessionMessage - -logger = logging.getLogger(__name__) - -# Environment variables to inherit by default -DEFAULT_INHERITED_ENV_VARS = ( - [ - "APPDATA", - "HOMEDRIVE", - "HOMEPATH", - "LOCALAPPDATA", - "PATH", - "PATHEXT", - "PROCESSOR_ARCHITECTURE", - "SYSTEMDRIVE", - "SYSTEMROOT", - "TEMP", - "USERNAME", - "USERPROFILE", - ] - if sys.platform == "win32" - else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"] -) - -# Timeout for process termination before falling back to force kill -PROCESS_TERMINATION_TIMEOUT = 2.0 - - -def get_default_environment() -> dict[str, str]: - """ - Returns a default environment object including only environment variables deemed - safe to inherit. - """ - env: dict[str, str] = {} - - for key in DEFAULT_INHERITED_ENV_VARS: - value = os.environ.get(key) - if value is None: - continue # pragma: no cover - - if value.startswith("()"): # pragma: no cover - # Skip functions, which are a security risk - continue # pragma: no cover - - env[key] = value - - return env - - -class StdioServerParameters(BaseModel): - command: str - """The executable to run to start the server.""" - - args: list[str] = Field(default_factory=list) - """Command line arguments to pass to the executable.""" - - env: dict[str, str] | None = None - """ - The environment to use when spawning the process. - - If not specified, the result of get_default_environment() will be used. - """ - - cwd: str | Path | None = None - """The working directory to use when spawning the process.""" - - encoding: str = "utf-8" - """ - The text encoding used when sending/receiving messages to the server - - defaults to utf-8 - """ - - encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" - """ - The text encoding error handler. - - See https://docs.python.org/3/library/codecs.html#codec-base-classes for - explanations of possible values - """ - - -@asynccontextmanager -async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr): - """ - Client transport for stdio: this will connect to a server by spawning a - process and communicating with it over stdin/stdout. - """ - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - try: - command = _get_executable_command(server.command) - - # Open process with stderr piped for capture - process = await _create_platform_compatible_process( - command=command, - args=server.args, - env=({**get_default_environment(), **server.env} if server.env is not None else get_default_environment()), - errlog=errlog, - cwd=server.cwd, - ) - except OSError: - # Clean up streams if process creation fails - await read_stream.aclose() - await write_stream.aclose() - await read_stream_writer.aclose() - await write_stream_reader.aclose() - raise - - async def stdout_reader(): - assert process.stdout, "Opened process is missing stdout" - - try: - async with read_stream_writer: - buffer = "" - async for chunk in TextReceiveStream( - process.stdout, - encoding=server.encoding, - errors=server.encoding_error_handler, - ): - lines = (buffer + chunk).split("\n") - buffer = lines.pop() - - for line in lines: - try: - message = types.JSONRPCMessage.model_validate_json(line) - except Exception as exc: # pragma: no cover - logger.exception("Failed to parse JSONRPC message from server") - await read_stream_writer.send(exc) - continue - - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: no cover - await anyio.lowlevel.checkpoint() - - async def stdin_writer(): - assert process.stdin, "Opened process is missing stdin" - - try: - async with write_stream_reader: - async for session_message in write_stream_reader: - json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) - await process.stdin.send( - (json + "\n").encode( - encoding=server.encoding, - errors=server.encoding_error_handler, - ) - ) - except anyio.ClosedResourceError: # pragma: no cover - await anyio.lowlevel.checkpoint() - - async with ( - anyio.create_task_group() as tg, - process, - ): - tg.start_soon(stdout_reader) - tg.start_soon(stdin_writer) - try: - yield read_stream, write_stream - finally: - # MCP spec: stdio shutdown sequence - # 1. Close input stream to server - # 2. Wait for server to exit, or send SIGTERM if it doesn't exit in time - # 3. Send SIGKILL if still not exited - if process.stdin: # pragma: no branch - try: - await process.stdin.aclose() - except Exception: # pragma: no cover - # stdin might already be closed, which is fine - pass - - try: - # Give the process time to exit gracefully after stdin closes - with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT): - await process.wait() - except TimeoutError: - # Process didn't exit from stdin closure, use platform-specific termination - # which handles SIGTERM -> SIGKILL escalation - await _terminate_process_tree(process) - except ProcessLookupError: # pragma: no cover - # Process already exited, which is fine - pass - await read_stream.aclose() - await write_stream.aclose() - await read_stream_writer.aclose() - await write_stream_reader.aclose() - - -def _get_executable_command(command: str) -> str: - """ - Get the correct executable command normalized for the current platform. - - Args: - command: Base command (e.g., 'uvx', 'npx') - - Returns: - str: Platform-appropriate command - """ - if sys.platform == "win32": # pragma: no cover - return get_windows_executable_command(command) - else: - return command # pragma: no cover - - -async def _create_platform_compatible_process( - command: str, - args: list[str], - env: dict[str, str] | None = None, - errlog: TextIO = sys.stderr, - cwd: Path | str | None = None, -): - """ - Creates a subprocess in a platform-compatible way. - - Unix: Creates process in a new session/process group for killpg support - Windows: Creates process in a Job Object for reliable child termination - """ - if sys.platform == "win32": # pragma: no cover - process = await create_windows_process(command, args, env, errlog, cwd) - else: - process = await anyio.open_process( - [command, *args], - env=env, - stderr=errlog, - cwd=cwd, - start_new_session=True, - ) # pragma: no cover - - return process - - -async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: - """ - Terminate a process and all its children using platform-specific methods. - - Unix: Uses os.killpg() for atomic process group termination - Windows: Uses Job Objects via pywin32 for reliable child process cleanup - - Args: - process: The process to terminate - timeout_seconds: Timeout in seconds before force killing (default: 2.0) - """ - if sys.platform == "win32": # pragma: no cover - await terminate_windows_process_tree(process, timeout_seconds) - else: # pragma: no cover - # FallbackProcess should only be used for Windows compatibility - assert isinstance(process, Process) - await terminate_posix_process_tree(process, timeout_seconds) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/streamable_http.py b/src/agent_workflow/mcp/sdk/src/mcp/client/streamable_http.py deleted file mode 100644 index ed28fcc..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/streamable_http.py +++ /dev/null @@ -1,722 +0,0 @@ -""" -StreamableHTTP Client Transport Module - -This module implements the StreamableHTTP transport for MCP clients, -providing support for HTTP POST requests with optional SSE streaming responses -and session management. -""" - -import contextlib -import logging -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import asynccontextmanager -from dataclasses import dataclass -from datetime import timedelta -from typing import Any, overload -from warnings import warn - -import anyio -import httpx -from anyio.abc import TaskGroup -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx_sse import EventSource, ServerSentEvent, aconnect_sse -from typing_extensions import deprecated - -from mcp.shared._httpx_utils import ( - McpHttpClientFactory, - create_mcp_http_client, -) -from mcp.shared.message import ClientMessageMetadata, SessionMessage -from mcp.types import ( - ErrorData, - InitializeResult, - JSONRPCError, - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - RequestId, -) - -logger = logging.getLogger(__name__) - - -SessionMessageOrError = SessionMessage | Exception -StreamWriter = MemoryObjectSendStream[SessionMessageOrError] -StreamReader = MemoryObjectReceiveStream[SessionMessage] -GetSessionIdCallback = Callable[[], str | None] - -MCP_SESSION_ID = "mcp-session-id" -MCP_PROTOCOL_VERSION = "mcp-protocol-version" -LAST_EVENT_ID = "last-event-id" - -# Reconnection defaults -DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry -MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up -CONTENT_TYPE = "content-type" -ACCEPT = "accept" - - -JSON = "application/json" -SSE = "text/event-stream" - -# Sentinel value for detecting unset optional parameters -_UNSET = object() - - -class StreamableHTTPError(Exception): - """Base exception for StreamableHTTP transport errors.""" - - -class ResumptionError(StreamableHTTPError): - """Raised when resumption request is invalid.""" - - -@dataclass -class RequestContext: - """Context for a request operation.""" - - client: httpx.AsyncClient - session_id: str | None - session_message: SessionMessage - metadata: ClientMessageMetadata | None - read_stream_writer: StreamWriter - headers: dict[str, str] | None = None # Deprecated - no longer used - sse_read_timeout: float | None = None # Deprecated - no longer used - - -class StreamableHTTPTransport: - """StreamableHTTP client transport implementation.""" - - @overload - def __init__(self, url: str) -> None: ... - - @overload - @deprecated( - "Parameters headers, timeout, sse_read_timeout, and auth are deprecated. " - "Configure these on the httpx.AsyncClient instead." - ) - def __init__( - self, - url: str, - headers: dict[str, str] | None = None, - timeout: float | timedelta = 30, - sse_read_timeout: float | timedelta = 60 * 5, - auth: httpx.Auth | None = None, - ) -> None: ... - - def __init__( - self, - url: str, - headers: Any = _UNSET, - timeout: Any = _UNSET, - sse_read_timeout: Any = _UNSET, - auth: Any = _UNSET, - ) -> None: - """Initialize the StreamableHTTP transport. - - Args: - url: The endpoint URL. - headers: Optional headers to include in requests. - timeout: HTTP timeout for regular operations. - sse_read_timeout: Timeout for SSE read operations. - auth: Optional HTTPX authentication handler. - """ - # Check for deprecated parameters and issue runtime warning - deprecated_params: list[str] = [] - if headers is not _UNSET: - deprecated_params.append("headers") - if timeout is not _UNSET: - deprecated_params.append("timeout") - if sse_read_timeout is not _UNSET: - deprecated_params.append("sse_read_timeout") - if auth is not _UNSET: - deprecated_params.append("auth") - - if deprecated_params: - warn( - f"Parameters {', '.join(deprecated_params)} are deprecated and will be ignored. " - "Configure these on the httpx.AsyncClient instead.", - DeprecationWarning, - stacklevel=2, - ) - - self.url = url - self.session_id = None - self.protocol_version = None - - def _prepare_headers(self) -> dict[str, str]: - """Build MCP-specific request headers. - - These headers will be merged with the httpx.AsyncClient's default headers, - with these MCP-specific headers taking precedence. - """ - headers: dict[str, str] = {} - # Add MCP protocol headers - headers[ACCEPT] = f"{JSON}, {SSE}" - headers[CONTENT_TYPE] = JSON - # Add session headers if available - if self.session_id: - headers[MCP_SESSION_ID] = self.session_id - if self.protocol_version: - headers[MCP_PROTOCOL_VERSION] = self.protocol_version - return headers - - def _is_initialization_request(self, message: JSONRPCMessage) -> bool: - """Check if the message is an initialization request.""" - return isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" - - def _is_initialized_notification(self, message: JSONRPCMessage) -> bool: - """Check if the message is an initialized notification.""" - return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized" - - def _maybe_extract_session_id_from_response( - self, - response: httpx.Response, - ) -> None: - """Extract and store session ID from response headers.""" - new_session_id = response.headers.get(MCP_SESSION_ID) - if new_session_id: - self.session_id = new_session_id - logger.info(f"Received session ID: {self.session_id}") - - def _maybe_extract_protocol_version_from_message( - self, - message: JSONRPCMessage, - ) -> None: - """Extract protocol version from initialization response message.""" - if isinstance(message.root, JSONRPCResponse) and message.root.result: # pragma: no branch - try: - # Parse the result as InitializeResult for type safety - init_result = InitializeResult.model_validate(message.root.result) - self.protocol_version = str(init_result.protocolVersion) - logger.info(f"Negotiated protocol version: {self.protocol_version}") - except Exception as exc: # pragma: no cover - logger.warning( - f"Failed to parse initialization response as InitializeResult: {exc}" - ) # pragma: no cover - logger.warning(f"Raw result: {message.root.result}") - - async def _handle_sse_event( - self, - sse: ServerSentEvent, - read_stream_writer: StreamWriter, - original_request_id: RequestId | None = None, - resumption_callback: Callable[[str], Awaitable[None]] | None = None, - is_initialization: bool = False, - ) -> bool: - """Handle an SSE event, returning True if the response is complete.""" - if sse.event == "message": - # Handle priming events (empty data with ID) for resumability - if not sse.data: - # Call resumption callback for priming events that have an ID - if sse.id and resumption_callback: - await resumption_callback(sse.id) - return False - try: - message = JSONRPCMessage.model_validate_json(sse.data) - logger.debug(f"SSE message: {message}") - - # Extract protocol version from initialization response - if is_initialization: - self._maybe_extract_protocol_version_from_message(message) - - # If this is a response and we have original_request_id, replace it - if original_request_id is not None and isinstance(message.root, JSONRPCResponse | JSONRPCError): - message.root.id = original_request_id - - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - - # Call resumption token callback if we have an ID - if sse.id and resumption_callback: - await resumption_callback(sse.id) - - # If this is a response or error return True indicating completion - # Otherwise, return False to continue listening - return isinstance(message.root, JSONRPCResponse | JSONRPCError) - - except Exception as exc: # pragma: no cover - logger.exception("Error parsing SSE message") - await read_stream_writer.send(exc) - return False - else: # pragma: no cover - logger.warning(f"Unknown SSE event: {sse.event}") - return False - - async def handle_get_stream( - self, - client: httpx.AsyncClient, - read_stream_writer: StreamWriter, - ) -> None: - """Handle GET stream for server-initiated messages with auto-reconnect.""" - last_event_id: str | None = None - retry_interval_ms: int | None = None - attempt: int = 0 - - while attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch - try: - if not self.session_id: - return - - headers = self._prepare_headers() - if last_event_id: - headers[LAST_EVENT_ID] = last_event_id # pragma: no cover - - async with aconnect_sse( - client, - "GET", - self.url, - headers=headers, - ) as event_source: - event_source.response.raise_for_status() - logger.debug("GET SSE connection established") - - async for sse in event_source.aiter_sse(): - # Track last event ID for reconnection - if sse.id: - last_event_id = sse.id # pragma: no cover - # Track retry interval from server - if sse.retry is not None: - retry_interval_ms = sse.retry # pragma: no cover - - await self._handle_sse_event(sse, read_stream_writer) - - # Stream ended normally (server closed) - reset attempt counter - attempt = 0 - - except Exception as exc: # pragma: no cover - logger.debug(f"GET stream error: {exc}") - attempt += 1 - - if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover - logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") - return - - # Wait before reconnecting - delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS - logger.info(f"GET stream disconnected, reconnecting in {delay_ms}ms...") - await anyio.sleep(delay_ms / 1000.0) - - async def _handle_resumption_request(self, ctx: RequestContext) -> None: - """Handle a resumption request using GET with SSE.""" - headers = self._prepare_headers() - if ctx.metadata and ctx.metadata.resumption_token: - headers[LAST_EVENT_ID] = ctx.metadata.resumption_token - else: - raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover - - # Extract original request ID to map responses - original_request_id = None - if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.root.id - - async with aconnect_sse( - ctx.client, - "GET", - self.url, - headers=headers, - ) as event_source: - event_source.response.raise_for_status() - logger.debug("Resumption GET SSE connection established") - - async for sse in event_source.aiter_sse(): # pragma: no branch - is_complete = await self._handle_sse_event( - sse, - ctx.read_stream_writer, - original_request_id, - ctx.metadata.on_resumption_token_update if ctx.metadata else None, - ) - if is_complete: - await event_source.response.aclose() - break - - async def _handle_post_request(self, ctx: RequestContext) -> None: - """Handle a POST request with response processing.""" - headers = self._prepare_headers() - message = ctx.session_message.message - is_initialization = self._is_initialization_request(message) - - async with ctx.client.stream( - "POST", - self.url, - json=message.model_dump(by_alias=True, mode="json", exclude_none=True), - headers=headers, - ) as response: - if response.status_code == 202: - logger.debug("Received 202 Accepted") - return - - if response.status_code == 404: # pragma: no branch - if isinstance(message.root, JSONRPCRequest): - await self._send_session_terminated_error( # pragma: no cover - ctx.read_stream_writer, # pragma: no cover - message.root.id, # pragma: no cover - ) # pragma: no cover - return # pragma: no cover - - response.raise_for_status() - if is_initialization: - self._maybe_extract_session_id_from_response(response) - - # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: - # The server MUST NOT send a response to notifications. - if isinstance(message.root, JSONRPCRequest): - content_type = response.headers.get(CONTENT_TYPE, "").lower() - if content_type.startswith(JSON): - await self._handle_json_response(response, ctx.read_stream_writer, is_initialization) - elif content_type.startswith(SSE): - await self._handle_sse_response(response, ctx, is_initialization) - else: - await self._handle_unexpected_content_type( # pragma: no cover - content_type, # pragma: no cover - ctx.read_stream_writer, # pragma: no cover - ) # pragma: no cover - - async def _handle_json_response( - self, - response: httpx.Response, - read_stream_writer: StreamWriter, - is_initialization: bool = False, - ) -> None: - """Handle JSON response from the server.""" - try: - content = await response.aread() - message = JSONRPCMessage.model_validate_json(content) - - # Extract protocol version from initialization response - if is_initialization: - self._maybe_extract_protocol_version_from_message(message) - - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - except Exception as exc: # pragma: no cover - logger.exception("Error parsing JSON response") - await read_stream_writer.send(exc) - - async def _handle_sse_response( - self, - response: httpx.Response, - ctx: RequestContext, - is_initialization: bool = False, - ) -> None: - """Handle SSE response from the server.""" - last_event_id: str | None = None - retry_interval_ms: int | None = None - - try: - event_source = EventSource(response) - async for sse in event_source.aiter_sse(): # pragma: no branch - # Track last event ID for potential reconnection - if sse.id: - last_event_id = sse.id - - # Track retry interval from server - if sse.retry is not None: - retry_interval_ms = sse.retry - - is_complete = await self._handle_sse_event( - sse, - ctx.read_stream_writer, - resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), - is_initialization=is_initialization, - ) - # If the SSE event indicates completion, like returning respose/error - # break the loop - if is_complete: - await response.aclose() - return # Normal completion, no reconnect needed - except Exception as e: # pragma: no cover - logger.debug(f"SSE stream ended: {e}") - - # Stream ended without response - reconnect if we received an event with ID - if last_event_id is not None: # pragma: no branch - logger.info("SSE stream disconnected, reconnecting...") - await self._handle_reconnection(ctx, last_event_id, retry_interval_ms) - - async def _handle_reconnection( - self, - ctx: RequestContext, - last_event_id: str, - retry_interval_ms: int | None = None, - attempt: int = 0, - ) -> None: - """Reconnect with Last-Event-ID to resume stream after server disconnect.""" - # Bail if max retries exceeded - if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover - logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") - return - - # Always wait - use server value or default - delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS - await anyio.sleep(delay_ms / 1000.0) - - headers = self._prepare_headers() - headers[LAST_EVENT_ID] = last_event_id - - # Extract original request ID to map responses - original_request_id = None - if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.root.id - - try: - async with aconnect_sse( - ctx.client, - "GET", - self.url, - headers=headers, - ) as event_source: - event_source.response.raise_for_status() - logger.info("Reconnected to SSE stream") - - # Track for potential further reconnection - reconnect_last_event_id: str = last_event_id - reconnect_retry_ms = retry_interval_ms - - async for sse in event_source.aiter_sse(): - if sse.id: # pragma: no branch - reconnect_last_event_id = sse.id - if sse.retry is not None: - reconnect_retry_ms = sse.retry - - is_complete = await self._handle_sse_event( - sse, - ctx.read_stream_writer, - original_request_id, - ctx.metadata.on_resumption_token_update if ctx.metadata else None, - ) - if is_complete: - await event_source.response.aclose() - return - - # Stream ended again without response - reconnect again (reset attempt counter) - logger.info("SSE stream disconnected, reconnecting...") - await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0) - except Exception as e: # pragma: no cover - logger.debug(f"Reconnection failed: {e}") - # Try to reconnect again if we still have an event ID - await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1) - - async def _handle_unexpected_content_type( - self, - content_type: str, - read_stream_writer: StreamWriter, - ) -> None: # pragma: no cover - """Handle unexpected content type in response.""" - error_msg = f"Unexpected content type: {content_type}" # pragma: no cover - logger.error(error_msg) # pragma: no cover - await read_stream_writer.send(ValueError(error_msg)) # pragma: no cover - - async def _send_session_terminated_error( - self, - read_stream_writer: StreamWriter, - request_id: RequestId, - ) -> None: - """Send a session terminated error response.""" - jsonrpc_error = JSONRPCError( - jsonrpc="2.0", - id=request_id, - error=ErrorData(code=32600, message="Session terminated"), - ) - session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) - await read_stream_writer.send(session_message) - - async def post_writer( - self, - client: httpx.AsyncClient, - write_stream_reader: StreamReader, - read_stream_writer: StreamWriter, - write_stream: MemoryObjectSendStream[SessionMessage], - start_get_stream: Callable[[], None], - tg: TaskGroup, - ) -> None: - """Handle writing requests to the server.""" - try: - async with write_stream_reader: - async for session_message in write_stream_reader: - message = session_message.message - metadata = ( - session_message.metadata - if isinstance(session_message.metadata, ClientMessageMetadata) - else None - ) - - # Check if this is a resumption request - is_resumption = bool(metadata and metadata.resumption_token) - - logger.debug(f"Sending client message: {message}") - - # Handle initialized notification - if self._is_initialized_notification(message): - start_get_stream() - - ctx = RequestContext( - client=client, - session_id=self.session_id, - session_message=session_message, - metadata=metadata, - read_stream_writer=read_stream_writer, - ) - - async def handle_request_async(): - if is_resumption: - await self._handle_resumption_request(ctx) - else: - await self._handle_post_request(ctx) - - # If this is a request, start a new task to handle it - if isinstance(message.root, JSONRPCRequest): - tg.start_soon(handle_request_async) - else: - await handle_request_async() - - except Exception: - logger.exception("Error in post_writer") # pragma: no cover - finally: - await read_stream_writer.aclose() - await write_stream.aclose() - - async def terminate_session(self, client: httpx.AsyncClient) -> None: # pragma: no cover - """Terminate the session by sending a DELETE request.""" - if not self.session_id: - return - - try: - headers = self._prepare_headers() - response = await client.delete(self.url, headers=headers) - - if response.status_code == 405: - logger.debug("Server does not allow session termination") - elif response.status_code not in (200, 204): - logger.warning(f"Session termination failed: {response.status_code}") - except Exception as exc: - logger.warning(f"Session termination failed: {exc}") - - def get_session_id(self) -> str | None: - """Get the current session ID.""" - return self.session_id - - -@asynccontextmanager -async def streamable_http_client( - url: str, - *, - http_client: httpx.AsyncClient | None = None, - terminate_on_close: bool = True, -) -> AsyncGenerator[ - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - GetSessionIdCallback, - ], - None, -]: - """ - Client transport for StreamableHTTP. - - Args: - url: The MCP server endpoint URL. - http_client: Optional pre-configured httpx.AsyncClient. If None, a default - client with recommended MCP timeouts will be created. To configure headers, - authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. - terminate_on_close: If True, send a DELETE request to terminate the session - when the context exits. - - Yields: - Tuple containing: - - read_stream: Stream for reading messages from the server - - write_stream: Stream for sending messages to the server - - get_session_id_callback: Function to retrieve the current session ID - - Example: - See examples/snippets/clients/ for usage patterns. - """ - read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0) - write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) - - # Determine if we need to create and manage the client - client_provided = http_client is not None - client = http_client - - if client is None: - # Create default client with recommended MCP timeouts - client = create_mcp_http_client() - - transport = StreamableHTTPTransport(url) - - async with anyio.create_task_group() as tg: - try: - logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") - - async with contextlib.AsyncExitStack() as stack: - # Only manage client lifecycle if we created it - if not client_provided: - await stack.enter_async_context(client) - - def start_get_stream() -> None: - tg.start_soon(transport.handle_get_stream, client, read_stream_writer) - - tg.start_soon( - transport.post_writer, - client, - write_stream_reader, - read_stream_writer, - write_stream, - start_get_stream, - tg, - ) - - try: - yield ( - read_stream, - write_stream, - transport.get_session_id, - ) - finally: - if transport.session_id and terminate_on_close: - await transport.terminate_session(client) - tg.cancel_scope.cancel() - finally: - await read_stream_writer.aclose() - await write_stream.aclose() - - -@asynccontextmanager -@deprecated("Use `streamable_http_client` instead.") -async def streamablehttp_client( - url: str, - headers: dict[str, str] | None = None, - timeout: float | timedelta = 30, - sse_read_timeout: float | timedelta = 60 * 5, - terminate_on_close: bool = True, - httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, - auth: httpx.Auth | None = None, -) -> AsyncGenerator[ - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - GetSessionIdCallback, - ], - None, -]: - # Convert timeout parameters - timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout - sse_read_timeout_seconds = ( - sse_read_timeout.total_seconds() if isinstance(sse_read_timeout, timedelta) else sse_read_timeout - ) - - # Create httpx client using the factory with old-style parameters - client = httpx_client_factory( - headers=headers, - timeout=httpx.Timeout(timeout_seconds, read=sse_read_timeout_seconds), - auth=auth, - ) - - # Manage client lifecycle since we created it - async with client: - async with streamable_http_client( - url, - http_client=client, - terminate_on_close=terminate_on_close, - ) as streams: - yield streams diff --git a/src/agent_workflow/mcp/sdk/src/mcp/client/websocket.py b/src/agent_workflow/mcp/sdk/src/mcp/client/websocket.py deleted file mode 100644 index a0e1b98..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/client/websocket.py +++ /dev/null @@ -1,94 +0,0 @@ -import json -import logging -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import ValidationError -from typing_extensions import deprecated -from websockets.asyncio.client import connect as ws_connect -from websockets.typing import Subprotocol - -import mcp.types as types -from mcp.shared.message import SessionMessage - -logger = logging.getLogger(__name__) - - -@deprecated( - "The WebSocket client transport is deprecated and will be removed in mcp 2.0. WebSocket was never part of" - " the MCP specification; use the streamable HTTP transport (`streamable_http_client`) instead." -) -@asynccontextmanager -async def websocket_client( - url: str, -) -> AsyncGenerator[ - tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]], - None, -]: - """ - WebSocket client transport for MCP, symmetrical to the server version. - - Deprecated: this transport will be removed in mcp 2.0. WebSocket was never - part of the MCP specification; use the streamable HTTP transport instead. - - Connects to 'url' using the 'mcp' subprotocol, then yields: - (read_stream, write_stream) - - - read_stream: As you read from this stream, you'll receive either valid - JSONRPCMessage objects or Exception objects (when validation fails). - - write_stream: Write JSONRPCMessage objects to this stream to send them - over the WebSocket to the server. - """ - - # Create two in-memory streams: - # - One for incoming messages (read_stream, written by ws_reader) - # - One for outgoing messages (write_stream, read by ws_writer) - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - # Connect using websockets, requesting the "mcp" subprotocol - async with ws_connect(url, subprotocols=[Subprotocol("mcp")]) as ws: - - async def ws_reader(): - """ - Reads text messages from the WebSocket, parses them as JSON-RPC messages, - and sends them into read_stream_writer. - """ - async with read_stream_writer: - async for raw_text in ws: - try: - message = types.JSONRPCMessage.model_validate_json(raw_text) - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - except ValidationError as exc: # pragma: no cover - # If JSON parse or model validation fails, send the exception - await read_stream_writer.send(exc) - - async def ws_writer(): - """ - Reads JSON-RPC messages from write_stream_reader and - sends them to the server. - """ - async with write_stream_reader: - async for session_message in write_stream_reader: - # Convert to a dict, then to JSON - msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True) - await ws.send(json.dumps(msg_dict)) - - async with anyio.create_task_group() as tg: - # Start reader and writer tasks - tg.start_soon(ws_reader) - tg.start_soon(ws_writer) - - # Yield the receive/send streams - yield (read_stream, write_stream) - - # Once the caller's 'async with' block exits, we shut down - tg.cancel_scope.cancel() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/os/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/os/__init__.py deleted file mode 100644 index fa5dbc8..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/os/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Platform-specific utilities for MCP.""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/os/posix/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/os/posix/__init__.py deleted file mode 100644 index 23aff8b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/os/posix/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""POSIX-specific utilities for MCP.""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/os/posix/utilities.py b/src/agent_workflow/mcp/sdk/src/mcp/os/posix/utilities.py deleted file mode 100644 index dd1aea3..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/os/posix/utilities.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -POSIX-specific functionality for stdio client operations. -""" - -import logging -import os -import signal - -import anyio -from anyio.abc import Process - -logger = logging.getLogger(__name__) - - -async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None: - """ - Terminate a process and all its children on POSIX systems. - - Uses os.killpg() for atomic process group termination. - - Args: - process: The process to terminate - timeout_seconds: Timeout in seconds before force killing (default: 2.0) - """ - pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None) - if not pid: - # No PID means there's no process to terminate - it either never started, - # already exited, or we have an invalid process object - return - - try: - pgid = os.getpgid(pid) - os.killpg(pgid, signal.SIGTERM) - - with anyio.move_on_after(timeout_seconds): - while True: - try: - # Check if process group still exists (signal 0 = check only) - os.killpg(pgid, 0) - await anyio.sleep(0.1) - except ProcessLookupError: - return - - try: - os.killpg(pgid, signal.SIGKILL) - except ProcessLookupError: - pass - - except (ProcessLookupError, PermissionError, OSError) as e: - logger.warning(f"Process group termination failed for PID {pid}: {e}, falling back to simple terminate") - try: - process.terminate() - with anyio.fail_after(timeout_seconds): - await process.wait() - except Exception: - logger.warning(f"Process termination failed for PID {pid}, attempting force kill") - try: - process.kill() - except Exception: - logger.exception(f"Failed to kill process {pid}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/os/win32/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/os/win32/__init__.py deleted file mode 100644 index f1ebab9..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/os/win32/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Windows-specific utilities for MCP.""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/os/win32/utilities.py b/src/agent_workflow/mcp/sdk/src/mcp/os/win32/utilities.py deleted file mode 100644 index 962be02..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/os/win32/utilities.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -Windows-specific functionality for stdio client operations. -""" - -import logging -import shutil -import subprocess -import sys -from pathlib import Path -from typing import BinaryIO, TextIO, cast - -import anyio -from anyio import to_thread -from anyio.abc import Process -from anyio.streams.file import FileReadStream, FileWriteStream -from typing_extensions import deprecated - -logger = logging.getLogger("client.stdio.win32") - -# Windows-specific imports for Job Objects -if sys.platform == "win32": - import pywintypes - import win32api - import win32con - import win32job -else: - # Type stubs for non-Windows platforms - win32api = None - win32con = None - win32job = None - pywintypes = None - -JobHandle = int - - -def get_windows_executable_command(command: str) -> str: - """ - Get the correct executable command normalized for Windows. - - On Windows, commands might exist with specific extensions (.exe, .cmd, etc.) - that need to be located for proper execution. - - Args: - command: Base command (e.g., 'uvx', 'npx') - - Returns: - str: Windows-appropriate command path - """ - try: - # First check if command exists in PATH as-is - if command_path := shutil.which(command): - return command_path - - # Check for Windows-specific extensions - for ext in [".cmd", ".bat", ".exe", ".ps1"]: - ext_version = f"{command}{ext}" - if ext_path := shutil.which(ext_version): - return ext_path - - # For regular commands or if we couldn't find special versions - return command - except OSError: - # Handle file system errors during path resolution - # (permissions, broken symlinks, etc.) - return command - - -class FallbackProcess: - """ - A fallback process wrapper for Windows to handle async I/O - when using subprocess.Popen, which provides sync-only FileIO objects. - - This wraps stdin and stdout into async-compatible - streams (FileReadStream, FileWriteStream), - so that MCP clients expecting async streams can work properly. - """ - - def __init__(self, popen_obj: subprocess.Popen[bytes]): - self.popen: subprocess.Popen[bytes] = popen_obj - self.stdin_raw = popen_obj.stdin # type: ignore[assignment] - self.stdout_raw = popen_obj.stdout # type: ignore[assignment] - self.stderr = popen_obj.stderr # type: ignore[assignment] - - self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None - self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None - - async def __aenter__(self): - """Support async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: BaseException | None, - exc_val: BaseException | None, - exc_tb: object | None, - ) -> None: - """Terminate and wait on process exit inside a thread.""" - self.popen.terminate() - await to_thread.run_sync(self.popen.wait) - - # Close the file handles to prevent ResourceWarning - if self.stdin: - await self.stdin.aclose() - if self.stdout: - await self.stdout.aclose() - if self.stdin_raw: - self.stdin_raw.close() - if self.stdout_raw: - self.stdout_raw.close() - if self.stderr: - self.stderr.close() - - async def wait(self): - """Async wait for process completion.""" - return await to_thread.run_sync(self.popen.wait) - - def terminate(self): - """Terminate the subprocess immediately.""" - return self.popen.terminate() - - def kill(self) -> None: - """Kill the subprocess immediately (alias for terminate).""" - self.terminate() - - @property - def pid(self) -> int: - """Return the process ID.""" - return self.popen.pid - - -# ------------------------ -# Updated function -# ------------------------ - - -async def create_windows_process( - command: str, - args: list[str], - env: dict[str, str] | None = None, - errlog: TextIO | None = sys.stderr, - cwd: Path | str | None = None, -) -> Process | FallbackProcess: - """ - Creates a subprocess in a Windows-compatible way with Job Object support. - - Attempt to use anyio's open_process for async subprocess creation. - In some cases this will throw NotImplementedError on Windows, e.g. - when using the SelectorEventLoop which does not support async subprocesses. - In that case, we fall back to using subprocess.Popen. - - The process is automatically added to a Job Object to ensure all child - processes are terminated when the parent is terminated. - - Args: - command (str): The executable to run - args (list[str]): List of command line arguments - env (dict[str, str] | None): Environment variables - errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr) - cwd (Path | str | None): Working directory for the subprocess - - Returns: - Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams - """ - job = _create_job_object() - process = None - - try: - # First try using anyio with Windows-specific flags to hide console window - process = await anyio.open_process( - [command, *args], - env=env, - # Ensure we don't create console windows for each process - creationflags=subprocess.CREATE_NO_WINDOW # type: ignore - if hasattr(subprocess, "CREATE_NO_WINDOW") - else 0, - stderr=errlog, - cwd=cwd, - ) - except NotImplementedError: - # If Windows doesn't support async subprocess creation, use fallback - process = await _create_windows_fallback_process(command, args, env, errlog, cwd) - except Exception: - # Try again without creation flags - process = await anyio.open_process( - [command, *args], - env=env, - stderr=errlog, - cwd=cwd, - ) - - _maybe_assign_process_to_job(process, job) - return process - - -async def _create_windows_fallback_process( - command: str, - args: list[str], - env: dict[str, str] | None = None, - errlog: TextIO | None = sys.stderr, - cwd: Path | str | None = None, -) -> FallbackProcess: - """ - Create a subprocess using subprocess.Popen as a fallback when anyio fails. - - This function wraps the sync subprocess.Popen in an async-compatible interface. - """ - try: - # Try launching with creationflags to avoid opening a new console window - popen_obj = subprocess.Popen( - [command, *args], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=errlog, - env=env, - cwd=cwd, - bufsize=0, # Unbuffered output - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - except Exception: - # If creationflags failed, fallback without them - popen_obj = subprocess.Popen( - [command, *args], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=errlog, - env=env, - cwd=cwd, - bufsize=0, - ) - return FallbackProcess(popen_obj) - - -def _create_job_object() -> int | None: - """ - Create a Windows Job Object configured to terminate all processes when closed. - """ - if sys.platform != "win32" or not win32job: - return None - - try: - job = win32job.CreateJobObject(None, "") - extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation) - - extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE - win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info) - return job - except Exception as e: - logger.warning(f"Failed to create Job Object for process tree management: {e}") - return None - - -def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHandle | None) -> None: - """ - Try to assign a process to a job object. If assignment fails - for any reason, the job handle is closed. - """ - if not job: - return - - if sys.platform != "win32" or not win32api or not win32con or not win32job: - return - - try: - process_handle = win32api.OpenProcess( - win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid - ) - if not process_handle: - raise Exception("Failed to open process handle") - - try: - win32job.AssignProcessToJobObject(job, process_handle) - process._job_object = job - finally: - win32api.CloseHandle(process_handle) - except Exception as e: - logger.warning(f"Failed to assign process {process.pid} to Job Object: {e}") - if win32api: - win32api.CloseHandle(job) - - -async def terminate_windows_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: - """ - Terminate a process and all its children on Windows. - - If the process has an associated job object, it will be terminated. - Otherwise, falls back to basic process termination. - - Args: - process: The process to terminate - timeout_seconds: Timeout in seconds before force killing (default: 2.0) - """ - if sys.platform != "win32": - return - - job = getattr(process, "_job_object", None) - if job and win32job: - try: - win32job.TerminateJobObject(job, 1) - except Exception: - # Job might already be terminated - pass - finally: - if win32api: - try: - win32api.CloseHandle(job) - except Exception: - pass - - # Always try to terminate the process itself as well - try: - process.terminate() - except Exception: - pass - - -@deprecated( - "terminate_windows_process is deprecated and will be removed in a future version. " - "Process termination is now handled internally by the stdio_client context manager." -) -async def terminate_windows_process(process: Process | FallbackProcess): - """ - Terminate a Windows process. - - Note: On Windows, terminating a process with process.terminate() doesn't - always guarantee immediate process termination. - So we give it 2s to exit, or we call process.kill() - which sends a SIGKILL equivalent signal. - - Args: - process: The process to terminate - """ - try: - process.terminate() - with anyio.fail_after(2.0): - await process.wait() - except TimeoutError: - # Force kill if it doesn't terminate - process.kill() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/py.typed b/src/agent_workflow/mcp/sdk/src/mcp/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/__init__.py deleted file mode 100644 index 0feed36..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .fastmcp import FastMCP -from .lowlevel import NotificationOptions, Server -from .models import InitializationOptions - -__all__ = ["Server", "FastMCP", "NotificationOptions", "InitializationOptions"] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/__main__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/__main__.py deleted file mode 100644 index 1970eca..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/__main__.py +++ /dev/null @@ -1,50 +0,0 @@ -import importlib.metadata -import logging -import sys - -import anyio - -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.server.stdio import stdio_server -from mcp.types import ServerCapabilities - -if not sys.warnoptions: - import warnings - - warnings.simplefilter("ignore") - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("server") - - -async def receive_loop(session: ServerSession): - logger.info("Starting receive loop") - async for message in session.incoming_messages: - if isinstance(message, Exception): - logger.error("Error: %s", message) - continue - - logger.info("Received message from client: %s", message) - - -async def main(): - version = importlib.metadata.version("mcp") - async with stdio_server() as (read_stream, write_stream): - async with ( - ServerSession( - read_stream, - write_stream, - InitializationOptions( - server_name="mcp", - server_version=version, - capabilities=ServerCapabilities(), - ), - ) as session, - write_stream, - ): - await receive_loop(session) - - -if __name__ == "__main__": - anyio.run(main, backend="trio") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/__init__.py deleted file mode 100644 index 6888ffe..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -MCP OAuth server authorization components. -""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/errors.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/errors.py deleted file mode 100644 index 117deea..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/errors.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import ValidationError - - -def stringify_pydantic_error(validation_error: ValidationError) -> str: - return "\n".join(f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in validation_error.errors()) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/__init__.py deleted file mode 100644 index e99a62d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Request handlers for MCP authorization endpoints. -""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/authorize.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/authorize.py deleted file mode 100644 index 3570d28..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/authorize.py +++ /dev/null @@ -1,224 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Any, Literal - -from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError -from starlette.datastructures import FormData, QueryParams -from starlette.requests import Request -from starlette.responses import RedirectResponse, Response - -from mcp.server.auth.errors import stringify_pydantic_error -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.server.auth.provider import ( - AuthorizationErrorCode, - AuthorizationParams, - AuthorizeError, - OAuthAuthorizationServerProvider, - construct_redirect_uri, -) -from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError - -logger = logging.getLogger(__name__) - - -class AuthorizationRequest(BaseModel): - # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 - client_id: str = Field(..., description="The client ID") - redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization") - - # see OAuthClientMetadata; we only support `code` - response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow") - code_challenge: str = Field(..., description="PKCE code challenge") - code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method, must be S256") - state: str | None = Field(None, description="Optional state parameter") - scope: str | None = Field( - None, - description="Optional scope; if specified, should be a space-separated list of scope strings", - ) - resource: str | None = Field( - None, - description="RFC 8707 resource indicator - the MCP server this token will be used with", - ) - - -class AuthorizationErrorResponse(BaseModel): - error: AuthorizationErrorCode - error_description: str | None - error_uri: AnyUrl | None = None - # must be set if provided in the request - state: str | None = None - - -def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None: - if params is None: # pragma: no cover - return None - value = params.get(key) - if isinstance(value, str): - return value - return None - - -class AnyUrlModel(RootModel[AnyUrl]): - root: AnyUrl - - -@dataclass -class AuthorizationHandler: - provider: OAuthAuthorizationServerProvider[Any, Any, Any] - - async def handle(self, request: Request) -> Response: - # implements authorization requests for grant_type=code; - # see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 - - state = None - redirect_uri = None - client = None - params = None - - async def error_response( - error: AuthorizationErrorCode, - error_description: str | None, - attempt_load_client: bool = True, - ): - # Error responses take two different formats: - # 1. The request has a valid client ID & redirect_uri: we issue a redirect - # back to the redirect_uri with the error response fields as query - # parameters. This allows the client to be notified of the error. - # 2. Otherwise, we return an error response directly to the end user; - # we choose to do so in JSON, but this is left undefined in the - # specification. - # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 - # - # This logic is a bit awkward to handle, because the error might be thrown - # very early in request validation, before we've done the usual Pydantic - # validation, loaded the client, etc. To handle this, error_response() - # contains fallback logic which attempts to load the parameters directly - # from the request. - - nonlocal client, redirect_uri, state - if client is None and attempt_load_client: - # make last-ditch attempt to load the client - client_id = best_effort_extract_string("client_id", params) - client = await self.provider.get_client(client_id) if client_id else None - if redirect_uri is None and client: - # make last-ditch effort to load the redirect uri - try: - if params is not None and "redirect_uri" not in params: - raw_redirect_uri = None - else: - raw_redirect_uri = AnyUrlModel.model_validate( - best_effort_extract_string("redirect_uri", params) - ).root - redirect_uri = client.validate_redirect_uri(raw_redirect_uri) - except (ValidationError, InvalidRedirectUriError): - # if the redirect URI is invalid, ignore it & just return the - # initial error - pass - - # the error response MUST contain the state specified by the client, if any - if state is None: # pragma: no cover - # make last-ditch effort to load state - state = best_effort_extract_string("state", params) - - error_resp = AuthorizationErrorResponse( - error=error, - error_description=error_description, - state=state, - ) - - if redirect_uri and client: - return RedirectResponse( - url=construct_redirect_uri(str(redirect_uri), **error_resp.model_dump(exclude_none=True)), - status_code=302, - headers={"Cache-Control": "no-store"}, - ) - else: - return PydanticJSONResponse( - status_code=400, - content=error_resp, - headers={"Cache-Control": "no-store"}, - ) - - try: - # Parse request parameters - if request.method == "GET": - # Convert query_params to dict for pydantic validation - params = request.query_params - else: - # Parse form data for POST requests - params = await request.form() - - # Save state if it exists, even before validation - state = best_effort_extract_string("state", params) - - try: - auth_request = AuthorizationRequest.model_validate(params) - state = auth_request.state # Update with validated state - except ValidationError as validation_error: - error: AuthorizationErrorCode = "invalid_request" - for e in validation_error.errors(): - if e["loc"] == ("response_type",) and e["type"] == "literal_error": - error = "unsupported_response_type" - break - return await error_response(error, stringify_pydantic_error(validation_error)) - - # Get client information - client = await self.provider.get_client( - auth_request.client_id, - ) - if not client: - # For client_id validation errors, return direct error (no redirect) - return await error_response( - error="invalid_request", - error_description=f"Client ID '{auth_request.client_id}' not found", - attempt_load_client=False, - ) - - # Validate redirect_uri against client's registered URIs - try: - redirect_uri = client.validate_redirect_uri(auth_request.redirect_uri) - except InvalidRedirectUriError as validation_error: - # For redirect_uri validation errors, return direct error (no redirect) - return await error_response( - error="invalid_request", - error_description=validation_error.message, - ) - - # Validate scope - for scope errors, we can redirect - try: - scopes = client.validate_scope(auth_request.scope) - except InvalidScopeError as validation_error: - # For scope errors, redirect with error parameters - return await error_response( - error="invalid_scope", - error_description=validation_error.message, - ) - - # Setup authorization parameters - auth_params = AuthorizationParams( - state=state, - scopes=scopes, - code_challenge=auth_request.code_challenge, - redirect_uri=redirect_uri, - redirect_uri_provided_explicitly=auth_request.redirect_uri is not None, - resource=auth_request.resource, # RFC 8707 - ) - - try: - # Let the provider pick the next URI to redirect to - return RedirectResponse( - url=await self.provider.authorize( - client, - auth_params, - ), - status_code=302, - headers={"Cache-Control": "no-store"}, - ) - except AuthorizeError as e: - # Handle authorization errors as defined in RFC 6749 Section 4.1.2.1 - return await error_response(error=e.error, error_description=e.error_description) - - except Exception as validation_error: # pragma: no cover - # Catch-all for unexpected errors - logger.exception("Unexpected error in authorization_handler", exc_info=validation_error) - return await error_response(error="server_error", error_description="An unexpected error occurred") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/metadata.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/metadata.py deleted file mode 100644 index f126442..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/metadata.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass - -from starlette.requests import Request -from starlette.responses import Response - -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata - - -@dataclass -class MetadataHandler: - metadata: OAuthMetadata - - async def handle(self, request: Request) -> Response: - return PydanticJSONResponse( - content=self.metadata, - headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour - ) - - -@dataclass -class ProtectedResourceMetadataHandler: - metadata: ProtectedResourceMetadata - - async def handle(self, request: Request) -> Response: - return PydanticJSONResponse( - content=self.metadata, - headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/register.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/register.py deleted file mode 100644 index c65473d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/register.py +++ /dev/null @@ -1,136 +0,0 @@ -import secrets -import time -from dataclasses import dataclass -from typing import Any -from uuid import uuid4 - -from pydantic import BaseModel, RootModel, ValidationError -from starlette.requests import Request -from starlette.responses import Response - -from mcp.server.auth.errors import stringify_pydantic_error -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode -from mcp.server.auth.settings import ClientRegistrationOptions -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata - - -class RegistrationRequest(RootModel[OAuthClientMetadata]): - # this wrapper is a no-op; it's just to separate out the types exposed to the - # provider from what we use in the HTTP handler - root: OAuthClientMetadata - - -class RegistrationErrorResponse(BaseModel): - error: RegistrationErrorCode - error_description: str | None - - -@dataclass -class RegistrationHandler: - provider: OAuthAuthorizationServerProvider[Any, Any, Any] - options: ClientRegistrationOptions - - async def handle(self, request: Request) -> Response: - # Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1 - try: - # Parse request body as JSON - body = await request.json() - client_metadata = OAuthClientMetadata.model_validate(body) - - # Scope validation is handled below - except ValidationError as validation_error: - return PydanticJSONResponse( - content=RegistrationErrorResponse( - error="invalid_client_metadata", - error_description=stringify_pydantic_error(validation_error), - ), - status_code=400, - ) - - client_id = str(uuid4()) - - # If auth method is None, default to client_secret_post - if client_metadata.token_endpoint_auth_method is None: - client_metadata.token_endpoint_auth_method = "client_secret_post" - - client_secret = None - if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch - # cryptographically secure random 32-byte hex string - client_secret = secrets.token_hex(32) - - if client_metadata.scope is None and self.options.default_scopes is not None: - client_metadata.scope = " ".join(self.options.default_scopes) - elif client_metadata.scope is not None and self.options.valid_scopes is not None: - requested_scopes = set(client_metadata.scope.split()) - valid_scopes = set(self.options.valid_scopes) - if not requested_scopes.issubset(valid_scopes): # pragma: no branch - return PydanticJSONResponse( - content=RegistrationErrorResponse( - error="invalid_client_metadata", - error_description="Requested scopes are not valid: " - f"{', '.join(requested_scopes - valid_scopes)}", - ), - status_code=400, - ) - if not {"authorization_code", "refresh_token"}.issubset(set(client_metadata.grant_types)): - return PydanticJSONResponse( - content=RegistrationErrorResponse( - error="invalid_client_metadata", - error_description="grant_types must be authorization_code and refresh_token", - ), - status_code=400, - ) - - # The MCP spec requires servers to use the authorization `code` flow - # with PKCE - if "code" not in client_metadata.response_types: - return PydanticJSONResponse( - content=RegistrationErrorResponse( - error="invalid_client_metadata", - error_description="response_types must include 'code' for authorization_code grant", - ), - status_code=400, - ) - - client_id_issued_at = int(time.time()) - client_secret_expires_at = ( - client_id_issued_at + self.options.client_secret_expiry_seconds - if self.options.client_secret_expiry_seconds is not None - else None - ) - - client_info = OAuthClientInformationFull( - client_id=client_id, - client_id_issued_at=client_id_issued_at, - client_secret=client_secret, - client_secret_expires_at=client_secret_expires_at, - # passthrough information from the client request - redirect_uris=client_metadata.redirect_uris, - token_endpoint_auth_method=client_metadata.token_endpoint_auth_method, - grant_types=client_metadata.grant_types, - response_types=client_metadata.response_types, - client_name=client_metadata.client_name, - client_uri=client_metadata.client_uri, - logo_uri=client_metadata.logo_uri, - scope=client_metadata.scope, - contacts=client_metadata.contacts, - tos_uri=client_metadata.tos_uri, - policy_uri=client_metadata.policy_uri, - jwks_uri=client_metadata.jwks_uri, - jwks=client_metadata.jwks, - software_id=client_metadata.software_id, - software_version=client_metadata.software_version, - ) - try: - # Register client - await self.provider.register_client(client_info) - - # Return client information - return PydanticJSONResponse(content=client_info, status_code=201) - except RegistrationError as e: - # Handle registration errors as defined in RFC 7591 Section 3.2.2 - return PydanticJSONResponse( - content=RegistrationErrorResponse(error=e.error, error_description=e.error_description), - status_code=400, - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/revoke.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/revoke.py deleted file mode 100644 index fa8cfc9..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/revoke.py +++ /dev/null @@ -1,91 +0,0 @@ -from dataclasses import dataclass -from functools import partial -from typing import Any, Literal - -from pydantic import BaseModel, ValidationError -from starlette.requests import Request -from starlette.responses import Response - -from mcp.server.auth.errors import ( - stringify_pydantic_error, -) -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator -from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken - - -class RevocationRequest(BaseModel): - """ - # See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 - """ - - token: str - token_type_hint: Literal["access_token", "refresh_token"] | None = None - client_id: str - client_secret: str | None - - -class RevocationErrorResponse(BaseModel): - error: Literal["invalid_request", "unauthorized_client"] - error_description: str | None = None - - -@dataclass -class RevocationHandler: - provider: OAuthAuthorizationServerProvider[Any, Any, Any] - client_authenticator: ClientAuthenticator - - async def handle(self, request: Request) -> Response: - """ - Handler for the OAuth 2.0 Token Revocation endpoint. - """ - try: - client = await self.client_authenticator.authenticate_request(request) - except AuthenticationError as e: # pragma: no cover - return PydanticJSONResponse( - status_code=401, - content=RevocationErrorResponse( - error="unauthorized_client", - error_description=e.message, - ), - ) - - try: - form_data = await request.form() - revocation_request = RevocationRequest.model_validate(dict(form_data)) - except ValidationError as e: - return PydanticJSONResponse( - status_code=400, - content=RevocationErrorResponse( - error="invalid_request", - error_description=stringify_pydantic_error(e), - ), - ) - - loaders = [ - self.provider.load_access_token, - partial(self.provider.load_refresh_token, client), - ] - if revocation_request.token_type_hint == "refresh_token": # pragma: no cover - loaders = reversed(loaders) - - token: None | AccessToken | RefreshToken = None - for loader in loaders: - token = await loader(revocation_request.token) - if token is not None: - break - - # if token is not found, just return HTTP 200 per the RFC - if token and token.client_id == client.client_id: - # Revoke token; provider is not meant to be able to do validation - # at this point that would result in an error - await self.provider.revoke_token(token) - - # Return successful empty response - return Response( - status_code=200, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/token.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/token.py deleted file mode 100644 index 4467da6..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/handlers/token.py +++ /dev/null @@ -1,241 +0,0 @@ -import base64 -import hashlib -import time -from dataclasses import dataclass -from typing import Annotated, Any, Literal - -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError -from starlette.requests import Request - -from mcp.server.auth.errors import stringify_pydantic_error -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator -from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError, TokenErrorCode -from mcp.shared.auth import OAuthToken - - -class AuthorizationCodeRequest(BaseModel): - # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 - grant_type: Literal["authorization_code"] - code: str = Field(..., description="The authorization code") - redirect_uri: AnyUrl | None = Field(None, description="Must be the same as redirect URI provided in /authorize") - client_id: str - # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 - client_secret: str | None = None - # See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 - code_verifier: str = Field(..., description="PKCE code verifier") - # RFC 8707 resource indicator - resource: str | None = Field(None, description="Resource indicator for the token") - - -class RefreshTokenRequest(BaseModel): - # See https://datatracker.ietf.org/doc/html/rfc6749#section-6 - grant_type: Literal["refresh_token"] - refresh_token: str = Field(..., description="The refresh token") - scope: str | None = Field(None, description="Optional scope parameter") - client_id: str - # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 - client_secret: str | None = None - # RFC 8707 resource indicator - resource: str | None = Field(None, description="Resource indicator for the token") - - -class TokenRequest( - RootModel[ - Annotated[ - AuthorizationCodeRequest | RefreshTokenRequest, - Field(discriminator="grant_type"), - ] - ] -): - root: Annotated[ - AuthorizationCodeRequest | RefreshTokenRequest, - Field(discriminator="grant_type"), - ] - - -class TokenErrorResponse(BaseModel): - """ - See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 - """ - - error: TokenErrorCode - error_description: str | None = None - error_uri: AnyHttpUrl | None = None - - -class TokenSuccessResponse(RootModel[OAuthToken]): - # this is just a wrapper over OAuthToken; the only reason we do this - # is to have some separation between the HTTP response type, and the - # type returned by the provider - root: OAuthToken - - -@dataclass -class TokenHandler: - provider: OAuthAuthorizationServerProvider[Any, Any, Any] - client_authenticator: ClientAuthenticator - - def response(self, obj: TokenSuccessResponse | TokenErrorResponse): - status_code = 200 - if isinstance(obj, TokenErrorResponse): - status_code = 400 - - return PydanticJSONResponse( - content=obj, - status_code=status_code, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - - async def handle(self, request: Request): - try: - client_info = await self.client_authenticator.authenticate_request(request) - except AuthenticationError as e: - # Authentication failures should return 401 - return PydanticJSONResponse( - content=TokenErrorResponse( - error="unauthorized_client", - error_description=e.message, - ), - status_code=401, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - - try: - form_data = await request.form() - token_request = TokenRequest.model_validate(dict(form_data)).root - except ValidationError as validation_error: # pragma: no cover - return self.response( - TokenErrorResponse( - error="invalid_request", - error_description=stringify_pydantic_error(validation_error), - ) - ) - - if token_request.grant_type not in client_info.grant_types: # pragma: no cover - return self.response( - TokenErrorResponse( - error="unsupported_grant_type", - error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"), - ) - ) - - tokens: OAuthToken - - match token_request: - case AuthorizationCodeRequest(): - auth_code = await self.provider.load_authorization_code(client_info, token_request.code) - if auth_code is None or auth_code.client_id != token_request.client_id: - # if code belongs to different client, pretend it doesn't exist - return self.response( - TokenErrorResponse( - error="invalid_grant", - error_description="authorization code does not exist", - ) - ) - - # make auth codes expire after a deadline - # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5 - if auth_code.expires_at < time.time(): - return self.response( - TokenErrorResponse( - error="invalid_grant", - error_description="authorization code has expired", - ) - ) - - # verify redirect_uri doesn't change between /authorize and /tokens - # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 - if auth_code.redirect_uri_provided_explicitly: - authorize_request_redirect_uri = auth_code.redirect_uri - else: # pragma: no cover - authorize_request_redirect_uri = None - - # Convert both sides to strings for comparison to handle AnyUrl vs string issues - token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None - auth_redirect_str = ( - str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None - ) - - if token_redirect_str != auth_redirect_str: - return self.response( - TokenErrorResponse( - error="invalid_request", - error_description=("redirect_uri did not match the one used when creating auth code"), - ) - ) - - # Verify PKCE code verifier - sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest() - hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=") - - if hashed_code_verifier != auth_code.code_challenge: - # see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 - return self.response( - TokenErrorResponse( - error="invalid_grant", - error_description="incorrect code_verifier", - ) - ) - - try: - # Exchange authorization code for tokens - tokens = await self.provider.exchange_authorization_code(client_info, auth_code) - except TokenError as e: - return self.response( - TokenErrorResponse( - error=e.error, - error_description=e.error_description, - ) - ) - - case RefreshTokenRequest(): # pragma: no cover - refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) - if refresh_token is None or refresh_token.client_id != token_request.client_id: - # if token belongs to different client, pretend it doesn't exist - return self.response( - TokenErrorResponse( - error="invalid_grant", - error_description="refresh token does not exist", - ) - ) - - if refresh_token.expires_at and refresh_token.expires_at < time.time(): - # if the refresh token has expired, pretend it doesn't exist - return self.response( - TokenErrorResponse( - error="invalid_grant", - error_description="refresh token has expired", - ) - ) - - # Parse scopes if provided - scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes - - for scope in scopes: - if scope not in refresh_token.scopes: - return self.response( - TokenErrorResponse( - error="invalid_scope", - error_description=(f"cannot request scope `{scope}` not provided by refresh token"), - ) - ) - - try: - # Exchange refresh token for new tokens - tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes) - except TokenError as e: - return self.response( - TokenErrorResponse( - error=e.error, - error_description=e.error_description, - ) - ) - - return self.response(TokenSuccessResponse(root=tokens)) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/json_response.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/json_response.py deleted file mode 100644 index bd95bd6..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/json_response.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any - -from starlette.responses import JSONResponse - - -class PydanticJSONResponse(JSONResponse): - # use pydantic json serialization instead of the stock `json.dumps`, - # so that we can handle serializing pydantic models like AnyHttpUrl - def render(self, content: Any) -> bytes: - return content.model_dump_json(exclude_none=True).encode("utf-8") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/__init__.py deleted file mode 100644 index ba3ff63..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Middleware for MCP authorization. -""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/auth_context.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/auth_context.py deleted file mode 100644 index e2116c3..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/auth_context.py +++ /dev/null @@ -1,48 +0,0 @@ -import contextvars - -from starlette.types import ASGIApp, Receive, Scope, Send - -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import AccessToken - -# Create a contextvar to store the authenticated user -# The default is None, indicating no authenticated user is present -auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None) - - -def get_access_token() -> AccessToken | None: - """ - Get the access token from the current context. - - Returns: - The access token if an authenticated user is available, None otherwise. - """ - auth_user = auth_context_var.get() - return auth_user.access_token if auth_user else None - - -class AuthContextMiddleware: - """ - Middleware that extracts the authenticated user from the request - and sets it in a contextvar for easy access throughout the request lifecycle. - - This middleware should be added after the AuthenticationMiddleware in the - middleware stack to ensure that the user is properly authenticated before - being stored in the context. - """ - - def __init__(self, app: ASGIApp): - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send): - user = scope.get("user") - if isinstance(user, AuthenticatedUser): - # Set the authenticated user in the contextvar - token = auth_context_var.set(user) - try: - await self.app(scope, receive, send) - finally: - auth_context_var.reset(token) - else: - # No authenticated user, just process the request - await self.app(scope, receive, send) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/bearer_auth.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/bearer_auth.py deleted file mode 100644 index 300b298..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/bearer_auth.py +++ /dev/null @@ -1,152 +0,0 @@ -import json -import time -from typing import Any, TypedDict - -from pydantic import AnyHttpUrl -from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser -from starlette.requests import HTTPConnection -from starlette.types import Receive, Scope, Send - -from mcp.server.auth.provider import AccessToken, TokenVerifier - - -class AuthenticatedUser(SimpleUser): - """User with authentication info.""" - - def __init__(self, auth_info: AccessToken): - super().__init__(auth_info.client_id) - self.access_token = auth_info - self.scopes = auth_info.scopes - - -class AuthorizationContext(TypedDict): - client_id: str - issuer: str | None - subject: str | None - - -def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: - """Identify the principal `user` represents, for transports to compare - against the principal that created a session. Components the token - verifier does not supply are `None`, so the comparison degrades to the - remaining components. - - See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for - a verifier that populates `subject` and `claims` from an introspection - response.""" - token = user.access_token - issuer = (token.claims or {}).get("iss") - return AuthorizationContext( - client_id=token.client_id, - issuer=str(issuer) if issuer is not None else None, - subject=token.subject, - ) - - -class BearerAuthBackend(AuthenticationBackend): - """ - Authentication backend that validates Bearer tokens using a TokenVerifier. - """ - - def __init__(self, token_verifier: TokenVerifier): - self.token_verifier = token_verifier - - async def authenticate(self, conn: HTTPConnection): - auth_header = next( - (conn.headers.get(key) for key in conn.headers if key.lower() == "authorization"), - None, - ) - if not auth_header or not auth_header.lower().startswith("bearer "): - return None - - token = auth_header[7:] # Remove "Bearer " prefix - - # Validate the token with the verifier - auth_info = await self.token_verifier.verify_token(token) - - if not auth_info: - return None - - if auth_info.expires_at and auth_info.expires_at < int(time.time()): - return None - - return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info) - - -class RequireAuthMiddleware: - """ - Middleware that requires a valid Bearer token in the Authorization header. - - This will validate the token with the auth provider and store the resulting - auth info in the request state. - """ - - def __init__( - self, - app: Any, - required_scopes: list[str], - resource_metadata_url: AnyHttpUrl | None = None, - ): - """ - Initialize the middleware. - - Args: - app: ASGI application - required_scopes: List of scopes that the token must have - resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header - """ - self.app = app - self.required_scopes = required_scopes - self.resource_metadata_url = resource_metadata_url - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - auth_user = scope.get("user") - if not isinstance(auth_user, AuthenticatedUser): - await self._send_auth_error( - send, status_code=401, error="invalid_token", description="Authentication required" - ) - return - - auth_credentials = scope.get("auth") - - for required_scope in self.required_scopes: - # auth_credentials should always be provided; this is just paranoia - if auth_credentials is None or required_scope not in auth_credentials.scopes: - await self._send_auth_error( - send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}" - ) - return - - await self.app(scope, receive, send) - - async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None: - """Send an authentication error response with WWW-Authenticate header.""" - # Build WWW-Authenticate header value - www_auth_parts = [f'error="{error}"', f'error_description="{description}"'] - if self.resource_metadata_url: # pragma: no cover - www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') - - www_authenticate = f"Bearer {', '.join(www_auth_parts)}" - - # Send response - body = {"error": error, "error_description": description} - body_bytes = json.dumps(body).encode() - - await send( - { - "type": "http.response.start", - "status": status_code, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body_bytes)).encode()), - (b"www-authenticate", www_authenticate.encode()), - ], - } - ) - - await send( - { - "type": "http.response.body", - "body": body_bytes, - } - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/client_auth.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/client_auth.py deleted file mode 100644 index 6126c6e..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/middleware/client_auth.py +++ /dev/null @@ -1,115 +0,0 @@ -import base64 -import binascii -import hmac -import time -from typing import Any -from urllib.parse import unquote - -from starlette.requests import Request - -from mcp.server.auth.provider import OAuthAuthorizationServerProvider -from mcp.shared.auth import OAuthClientInformationFull - - -class AuthenticationError(Exception): - def __init__(self, message: str): - self.message = message # pragma: no cover - - -class ClientAuthenticator: - """ - ClientAuthenticator is a callable which validates requests from a client - application, used to verify /token calls. - If, during registration, the client requested to be issued a secret, the - authenticator asserts that /token calls must be authenticated with - that same token. - NOTE: clients can opt for no authentication during registration, in which case this - logic is skipped. - """ - - def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]): - """ - Initialize the dependency. - - Args: - provider: Provider to look up client information - """ - self.provider = provider - - async def authenticate_request(self, request: Request) -> OAuthClientInformationFull: - """ - Authenticate a client from an HTTP request. - - Extracts client credentials from the appropriate location based on the - client's registered authentication method and validates them. - - Args: - request: The HTTP request containing client credentials - - Returns: - The authenticated client information - - Raises: - AuthenticationError: If authentication fails - """ - form_data = await request.form() - client_id = form_data.get("client_id") - if not client_id: - raise AuthenticationError("Missing client_id") - - client = await self.provider.get_client(str(client_id)) - if not client: - raise AuthenticationError("Invalid client_id") # pragma: no cover - - request_client_secret: str | None = None - auth_header = request.headers.get("Authorization", "") - - if client.token_endpoint_auth_method == "client_secret_basic": - if not auth_header.startswith("Basic "): - raise AuthenticationError("Missing or invalid Basic authentication in Authorization header") - - try: - encoded_credentials = auth_header[6:] # Remove "Basic " prefix - decoded = base64.b64decode(encoded_credentials).decode("utf-8") - if ":" not in decoded: - raise ValueError("Invalid Basic auth format") - basic_client_id, request_client_secret = decoded.split(":", 1) - - # URL-decode both parts per RFC 6749 Section 2.3.1 - basic_client_id = unquote(basic_client_id) - request_client_secret = unquote(request_client_secret) - - if basic_client_id != client_id: - raise AuthenticationError("Client ID mismatch in Basic auth") - except (ValueError, UnicodeDecodeError, binascii.Error): - raise AuthenticationError("Invalid Basic authentication header") - - elif client.token_endpoint_auth_method == "client_secret_post": - raw_form_data = form_data.get("client_secret") - # form_data.get() can return a UploadFile or None, so we need to check if it's a string - if isinstance(raw_form_data, str): - request_client_secret = str(raw_form_data) - - elif client.token_endpoint_auth_method == "none": - request_client_secret = None - else: - raise AuthenticationError( # pragma: no cover - f"Unsupported auth method: {client.token_endpoint_auth_method}" - ) - - # If client from the store expects a secret, validate that the request provides - # that secret - if client.client_secret: # pragma: no branch - if not request_client_secret: - raise AuthenticationError("Client secret is required") # pragma: no cover - - # hmac.compare_digest requires that both arguments are either bytes or a `str` containing - # only ASCII characters. Since we do not control `request_client_secret`, we encode both - # arguments to bytes. - if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()): - raise AuthenticationError("Invalid client_secret") # pragma: no cover - - if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()): - raise AuthenticationError("Client secret has expired") # pragma: no cover - - return client diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/provider.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/provider.py deleted file mode 100644 index 310baff..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/provider.py +++ /dev/null @@ -1,305 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Generic, Literal, Protocol, TypeVar -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse - -from pydantic import AnyUrl, BaseModel - -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken - - -class AuthorizationParams(BaseModel): - state: str | None - scopes: list[str] | None - code_challenge: str - redirect_uri: AnyUrl - redirect_uri_provided_explicitly: bool - resource: str | None = None # RFC 8707 resource indicator - - -class AuthorizationCode(BaseModel): - code: str - scopes: list[str] - expires_at: float - client_id: str - code_challenge: str - redirect_uri: AnyUrl - redirect_uri_provided_explicitly: bool - resource: str | None = None # RFC 8707 resource indicator - subject: str | None = None # resource owner; propagate to the issued AccessToken - - -class RefreshToken(BaseModel): - token: str - client_id: str - scopes: list[str] - expires_at: int | None = None - subject: str | None = None # resource owner; propagate to refreshed AccessTokens - - -class AccessToken(BaseModel): - token: str - client_id: str - scopes: list[str] - expires_at: int | None = None - resource: str | None = None # RFC 8707 resource indicator - subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer - claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`) - - -RegistrationErrorCode = Literal[ - "invalid_redirect_uri", - "invalid_client_metadata", - "invalid_software_statement", - "unapproved_software_statement", -] - - -@dataclass(frozen=True) -class RegistrationError(Exception): - error: RegistrationErrorCode - error_description: str | None = None - - -AuthorizationErrorCode = Literal[ - "invalid_request", - "unauthorized_client", - "access_denied", - "unsupported_response_type", - "invalid_scope", - "server_error", - "temporarily_unavailable", -] - - -@dataclass(frozen=True) -class AuthorizeError(Exception): - error: AuthorizationErrorCode - error_description: str | None = None - - -TokenErrorCode = Literal[ - "invalid_request", - "invalid_client", - "invalid_grant", - "unauthorized_client", - "unsupported_grant_type", - "invalid_scope", -] - - -@dataclass(frozen=True) -class TokenError(Exception): - error: TokenErrorCode - error_description: str | None = None - - -class TokenVerifier(Protocol): - """Protocol for verifying bearer tokens.""" - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a bearer token and return access info if valid.""" - - -# NOTE: FastMCP doesn't render any of these types in the user response, so it's -# OK to add fields to subclasses which should not be exposed externally. -AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode) -RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken) -AccessTokenT = TypeVar("AccessTokenT", bound=AccessToken) - - -class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]): - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """ - Retrieves client information by client ID. - - Implementors MAY raise NotImplementedError if dynamic client registration is - disabled in ClientRegistrationOptions. - - Args: - client_id: The ID of the client to retrieve. - - Returns: - The client information, or None if the client does not exist. - """ - - async def register_client(self, client_info: OAuthClientInformationFull) -> None: - """ - Saves client information as part of registering it. - - Implementors MAY raise NotImplementedError if dynamic client registration is - disabled in ClientRegistrationOptions. - - Args: - client_info: The client metadata to register. - - Raises: - RegistrationError: If the client metadata is invalid. - """ - - async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: - """ - Called as part of the /authorize endpoint, and returns a URL that the client - will be redirected to. - Many MCP implementations will redirect to a third-party provider to perform - a second OAuth exchange with that provider. In this sort of setup, the client - has an OAuth connection with the MCP server, and the MCP server has an OAuth - connection with the 3rd-party provider. At the end of this flow, the client - should be redirected to the redirect_uri from params.redirect_uri. - - +--------+ +------------+ +-------------------+ - | | | | | | - | Client | --> | MCP Server | --> | 3rd Party OAuth | - | | | | | Server | - +--------+ +------------+ +-------------------+ - | ^ | - +------------+ | | | - | | | | Redirect | - |redirect_uri|<-----+ +------------------+ - | | - +------------+ - - Implementations will need to define another handler on the MCP server return - flow to perform the second redirect, and generate and store an authorization - code as part of completing the OAuth authorization step. - - Implementations SHOULD generate an authorization code with at least 160 bits of - entropy, - and MUST generate an authorization code with at least 128 bits of entropy. - See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10. - - Args: - client: The client requesting authorization. - params: The parameters of the authorization request. - - Returns: - A URL to redirect the client to for authorization. - - Raises: - AuthorizeError: If the authorization request is invalid. - """ - ... - - async def load_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: str - ) -> AuthorizationCodeT | None: - """ - Loads an AuthorizationCode by its code. - - Args: - client: The client that requested the authorization code. - authorization_code: The authorization code to get the challenge for. - - Returns: - The AuthorizationCode, or None if not found - """ - ... - - async def exchange_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT - ) -> OAuthToken: - """ - Exchanges an authorization code for an access token and refresh token. - - Args: - client: The client exchanging the authorization code. - authorization_code: The authorization code to exchange. - - Returns: - The OAuth token, containing access and refresh tokens. - - Raises: - TokenError: If the request is invalid - """ - ... - - async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None: - """ - Loads a RefreshToken by its token string. - - Args: - client: The client that is requesting to load the refresh token. - refresh_token: The refresh token string to load. - - Returns: - The RefreshToken object if found, or None if not found. - """ - ... - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshTokenT, - scopes: list[str], - ) -> OAuthToken: - """ - Exchanges a refresh token for an access token and refresh token. - - Implementations SHOULD rotate both the access token and refresh token. - - Args: - client: The client exchanging the refresh token. - refresh_token: The refresh token to exchange. - scopes: Optional scopes to request with the new access token. - - Returns: - The OAuth token, containing access and refresh tokens. - - Raises: - TokenError: If the request is invalid - """ - ... - - async def load_access_token(self, token: str) -> AccessTokenT | None: - """ - Loads an access token by its token. - - Args: - token: The access token to verify. - - Returns: - The AuthInfo, or None if the token is invalid. - """ - - async def revoke_token( - self, - token: AccessTokenT | RefreshTokenT, - ) -> None: - """ - Revokes an access or refresh token. - - If the given token is invalid or already revoked, this method should do nothing. - - Implementations SHOULD revoke both the access token and its corresponding - refresh token, regardless of which of the access token or refresh token is - provided. - - Args: - token: the token to revoke - """ - - -def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str: - parsed_uri = urlparse(redirect_uri_base) - query_params = [(k, v) for k, vs in parse_qs(parsed_uri.query).items() for v in vs] - for k, v in params.items(): - if v is not None: - query_params.append((k, v)) - - redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params))) - return redirect_uri - - -class ProviderTokenVerifier(TokenVerifier): - """Token verifier that uses an OAuthAuthorizationServerProvider. - - This is provided for backwards compatibility with existing auth_server_provider - configurations. For new implementations using AS/RS separation, consider using - the TokenVerifier protocol with a dedicated implementation like IntrospectionTokenVerifier. - """ - - def __init__(self, provider: "OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]"): - self.provider = provider - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token using the provider's load_access_token method.""" - return await self.provider.load_access_token(token) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/routes.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/routes.py deleted file mode 100644 index 71a9c8b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/routes.py +++ /dev/null @@ -1,253 +0,0 @@ -from collections.abc import Awaitable, Callable -from typing import Any -from urllib.parse import urlparse - -from pydantic import AnyHttpUrl -from starlette.middleware.cors import CORSMiddleware -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import Route, request_response # type: ignore -from starlette.types import ASGIApp - -from mcp.server.auth.handlers.authorize import AuthorizationHandler -from mcp.server.auth.handlers.metadata import MetadataHandler -from mcp.server.auth.handlers.register import RegistrationHandler -from mcp.server.auth.handlers.revoke import RevocationHandler -from mcp.server.auth.handlers.token import TokenHandler -from mcp.server.auth.middleware.client_auth import ClientAuthenticator -from mcp.server.auth.provider import OAuthAuthorizationServerProvider -from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions -from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER -from mcp.shared.auth import OAuthMetadata - - -def validate_issuer_url(url: AnyHttpUrl): - """ - Validate that the issuer URL meets OAuth 2.0 requirements. - - Args: - url: The issuer URL to validate - - Raises: - ValueError: If the issuer URL is invalid - """ - - # RFC 8414 requires HTTPS, but we allow localhost HTTP for testing - if ( - url.scheme != "https" - and url.host != "localhost" - and (url.host is not None and not url.host.startswith("127.0.0.1")) - ): - raise ValueError("Issuer URL must be HTTPS") # pragma: no cover - - # No fragments or query parameters allowed - if url.fragment: - raise ValueError("Issuer URL must not have a fragment") # pragma: no cover - if url.query: - raise ValueError("Issuer URL must not have a query string") # pragma: no cover - - -AUTHORIZATION_PATH = "/authorize" -TOKEN_PATH = "/token" -REGISTRATION_PATH = "/register" -REVOCATION_PATH = "/revoke" - - -def cors_middleware( - handler: Callable[[Request], Response | Awaitable[Response]], - allow_methods: list[str], -) -> ASGIApp: - cors_app = CORSMiddleware( - app=request_response(handler), - allow_origins="*", - allow_methods=allow_methods, - allow_headers=[MCP_PROTOCOL_VERSION_HEADER], - ) - return cors_app - - -def create_auth_routes( - provider: OAuthAuthorizationServerProvider[Any, Any, Any], - issuer_url: AnyHttpUrl, - service_documentation_url: AnyHttpUrl | None = None, - client_registration_options: ClientRegistrationOptions | None = None, - revocation_options: RevocationOptions | None = None, -) -> list[Route]: - validate_issuer_url(issuer_url) - - client_registration_options = client_registration_options or ClientRegistrationOptions() - revocation_options = revocation_options or RevocationOptions() - metadata = build_metadata( - issuer_url, - service_documentation_url, - client_registration_options, - revocation_options, - ) - client_authenticator = ClientAuthenticator(provider) - - # Create routes - # Allow CORS requests for endpoints meant to be hit by the OAuth client - # (with the client secret). This is intended to support things like MCP Inspector, - # where the client runs in a web browser. - routes = [ - Route( - "/.well-known/oauth-authorization-server", - endpoint=cors_middleware( - MetadataHandler(metadata).handle, - ["GET", "OPTIONS"], - ), - methods=["GET", "OPTIONS"], - ), - Route( - AUTHORIZATION_PATH, - # do not allow CORS for authorization endpoint; - # clients should just redirect to this - endpoint=AuthorizationHandler(provider).handle, - methods=["GET", "POST"], - ), - Route( - TOKEN_PATH, - endpoint=cors_middleware( - TokenHandler(provider, client_authenticator).handle, - ["POST", "OPTIONS"], - ), - methods=["POST", "OPTIONS"], - ), - ] - - if client_registration_options.enabled: # pragma: no branch - registration_handler = RegistrationHandler( - provider, - options=client_registration_options, - ) - routes.append( - Route( - REGISTRATION_PATH, - endpoint=cors_middleware( - registration_handler.handle, - ["POST", "OPTIONS"], - ), - methods=["POST", "OPTIONS"], - ) - ) - - if revocation_options.enabled: # pragma: no branch - revocation_handler = RevocationHandler(provider, client_authenticator) - routes.append( - Route( - REVOCATION_PATH, - endpoint=cors_middleware( - revocation_handler.handle, - ["POST", "OPTIONS"], - ), - methods=["POST", "OPTIONS"], - ) - ) - - return routes - - -def build_metadata( - issuer_url: AnyHttpUrl, - service_documentation_url: AnyHttpUrl | None, - client_registration_options: ClientRegistrationOptions, - revocation_options: RevocationOptions, -) -> OAuthMetadata: - authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH) - token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH) - - # Create metadata - metadata = OAuthMetadata( - issuer=issuer_url, - authorization_endpoint=authorization_url, - token_endpoint=token_url, - scopes_supported=client_registration_options.valid_scopes, - response_types_supported=["code"], - response_modes_supported=None, - grant_types_supported=["authorization_code", "refresh_token"], - token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], - token_endpoint_auth_signing_alg_values_supported=None, - service_documentation=service_documentation_url, - ui_locales_supported=None, - op_policy_uri=None, - op_tos_uri=None, - introspection_endpoint=None, - code_challenge_methods_supported=["S256"], - ) - - # Add registration endpoint if supported - if client_registration_options.enabled: # pragma: no branch - metadata.registration_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REGISTRATION_PATH) - - # Add revocation endpoint if supported - if revocation_options.enabled: # pragma: no branch - metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH) - metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"] - - return metadata - - -def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl: - """ - Build RFC 9728 compliant protected resource metadata URL. - - Inserts /.well-known/oauth-protected-resource between host and resource path - as specified in RFC 9728 §3.1. - - Args: - resource_server_url: The resource server URL (e.g., https://example.com/mcp) - - Returns: - The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp) - """ - parsed = urlparse(str(resource_server_url)) - # Handle trailing slash: if path is just "/", treat as empty - resource_path = parsed.path if parsed.path != "/" else "" - return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}") - - -def create_protected_resource_routes( - resource_url: AnyHttpUrl, - authorization_servers: list[AnyHttpUrl], - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, -) -> list[Route]: - """ - Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). - - Args: - resource_url: The URL of this resource server - authorization_servers: List of authorization servers that can issue tokens - scopes_supported: Optional list of scopes supported by this resource - - Returns: - List of Starlette routes for protected resource metadata - """ - from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler - from mcp.shared.auth import ProtectedResourceMetadata - - metadata = ProtectedResourceMetadata( - resource=resource_url, - authorization_servers=authorization_servers, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - # bearer_methods_supported defaults to ["header"] in the model - ) - - handler = ProtectedResourceMetadataHandler(metadata) - - # RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path - metadata_url = build_resource_metadata_url(resource_url) - # Extract just the path part for route registration - parsed = urlparse(str(metadata_url)) - well_known_path = parsed.path - - return [ - Route( - well_known_path, - endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), - methods=["GET", "OPTIONS"], - ) - ] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/settings.py b/src/agent_workflow/mcp/sdk/src/mcp/server/auth/settings.py deleted file mode 100644 index 1649826..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/auth/settings.py +++ /dev/null @@ -1,30 +0,0 @@ -from pydantic import AnyHttpUrl, BaseModel, Field - - -class ClientRegistrationOptions(BaseModel): - enabled: bool = False - client_secret_expiry_seconds: int | None = None - valid_scopes: list[str] | None = None - default_scopes: list[str] | None = None - - -class RevocationOptions(BaseModel): - enabled: bool = False - - -class AuthSettings(BaseModel): - issuer_url: AnyHttpUrl = Field( - ..., - description="OAuth authorization server URL that issues tokens for this resource server.", - ) - service_documentation_url: AnyHttpUrl | None = None - client_registration_options: ClientRegistrationOptions | None = None - revocation_options: RevocationOptions | None = None - required_scopes: list[str] | None = None - - # Resource Server settings (when operating as RS only) - resource_server_url: AnyHttpUrl | None = Field( - ..., - description="The URL of the MCP server to be used as the resource identifier " - "and base route to look up OAuth Protected Resource Metadata.", - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/elicitation.py b/src/agent_workflow/mcp/sdk/src/mcp/server/elicitation.py deleted file mode 100644 index 4919541..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/elicitation.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Elicitation utilities for MCP servers.""" - -from __future__ import annotations - -import types -from collections.abc import Sequence -from typing import Generic, Literal, TypeVar, Union, get_args, get_origin - -from pydantic import BaseModel - -from mcp.server.session import ServerSession -from mcp.types import RequestId - -ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) - - -class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): - """Result when user accepts the elicitation.""" - - action: Literal["accept"] = "accept" - data: ElicitSchemaModelT - - -class DeclinedElicitation(BaseModel): - """Result when user declines the elicitation.""" - - action: Literal["decline"] = "decline" - - -class CancelledElicitation(BaseModel): - """Result when user cancels the elicitation.""" - - action: Literal["cancel"] = "cancel" - - -ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation - - -class AcceptedUrlElicitation(BaseModel): - """Result when user accepts a URL mode elicitation.""" - - action: Literal["accept"] = "accept" - - -UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation - - -# Primitive types allowed in elicitation schemas -_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool) - - -def _validate_elicitation_schema(schema: type[BaseModel]) -> None: - """Validate that a Pydantic model only contains primitive field types.""" - for field_name, field_info in schema.model_fields.items(): - annotation = field_info.annotation - - if annotation is None or annotation is types.NoneType: # pragma: no cover - continue - elif _is_primitive_field(annotation): - continue - elif _is_string_sequence(annotation): - continue - else: - raise TypeError( - f"Elicitation schema field '{field_name}' must be a primitive type " - f"{_ELICITATION_PRIMITIVE_TYPES}, a sequence of strings (list[str], etc.), " - f"or Optional of these types. Nested models and complex types are not allowed." - ) - - -def _is_string_sequence(annotation: type) -> bool: - """Check if annotation is a sequence of strings (list[str], Sequence[str], etc).""" - origin = get_origin(annotation) - # Check if it's a sequence-like type with str elements - if origin: - try: - if issubclass(origin, Sequence): - args = get_args(annotation) - # Should have single str type arg - return len(args) == 1 and args[0] is str - except TypeError: # pragma: no cover - # origin is not a class, so it can't be a subclass of Sequence - pass - return False - - -def _is_primitive_field(annotation: type) -> bool: - """Check if a field is a primitive type allowed in elicitation schemas.""" - # Handle basic primitive types - if annotation in _ELICITATION_PRIMITIVE_TYPES: - return True - - # Handle Union types - origin = get_origin(annotation) - if origin is Union or origin is types.UnionType: - args = get_args(annotation) - # All args must be primitive types, None, or string sequences - return all( - arg is types.NoneType or arg in _ELICITATION_PRIMITIVE_TYPES or _is_string_sequence(arg) for arg in args - ) - - return False - - -async def elicit_with_validation( - session: ServerSession, - message: str, - schema: type[ElicitSchemaModelT], - related_request_id: RequestId | None = None, -) -> ElicitationResult[ElicitSchemaModelT]: - """Elicit information from the client/user with schema validation (form mode). - - This method can be used to interactively ask for additional information from the - client within a tool's execution. The client might display the message to the - user and collect a response according to the provided schema. Or in case a - client is an agent, it might decide how to handle the elicitation -- either by asking - the user or automatically generating a response. - - For sensitive data like credentials or OAuth flows, use elicit_url() instead. - """ - # Validate that schema only contains primitive types and fail loudly if not - _validate_elicitation_schema(schema) - - json_schema = schema.model_json_schema() - - result = await session.elicit_form( - message=message, - requestedSchema=json_schema, - related_request_id=related_request_id, - ) - - if result.action == "accept" and result.content is not None: - # Validate and parse the content using the schema - validated_data = schema.model_validate(result.content) - return AcceptedElicitation(data=validated_data) - elif result.action == "decline": - return DeclinedElicitation() - elif result.action == "cancel": # pragma: no cover - return CancelledElicitation() - else: # pragma: no cover - # This should never happen, but handle it just in case - raise ValueError(f"Unexpected elicitation action: {result.action}") - - -async def elicit_url( - session: ServerSession, - message: str, - url: str, - elicitation_id: str, - related_request_id: RequestId | None = None, -) -> UrlElicitationResult: - """Elicit information from the user via out-of-band URL navigation (URL mode). - - This method directs the user to an external URL where sensitive interactions can - occur without passing data through the MCP client. Use this for: - - Collecting sensitive credentials (API keys, passwords) - - OAuth authorization flows with third-party services - - Payment and subscription flows - - Any interaction where data should not pass through the LLM context - - The response indicates whether the user consented to navigate to the URL. - The actual interaction happens out-of-band. When the elicitation completes, - the server should send an ElicitCompleteNotification to notify the client. - - Args: - session: The server session - message: Human-readable explanation of why the interaction is needed - url: The URL the user should navigate to - elicitation_id: Unique identifier for tracking this elicitation - related_request_id: Optional ID of the request that triggered this elicitation - - Returns: - UrlElicitationResult indicating accept, decline, or cancel - """ - result = await session.elicit_url( - message=message, - url=url, - elicitation_id=elicitation_id, - related_request_id=related_request_id, - ) - - if result.action == "accept": - return AcceptedUrlElicitation() - elif result.action == "decline": - return DeclinedElicitation() - elif result.action == "cancel": - return CancelledElicitation() - else: # pragma: no cover - # This should never happen, but handle it just in case - raise ValueError(f"Unexpected elicitation action: {result.action}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/__init__.py deleted file mode 100644 index 91c6dcf..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Server-side experimental features. - -WARNING: These APIs are experimental and may change without notice. - -Import directly from submodules: -- mcp.server.experimental.task_context.ServerTaskContext -- mcp.server.experimental.task_support.TaskSupport -- mcp.server.experimental.task_result_handler.TaskResultHandler -- mcp.server.experimental.request_context.Experimental -- mcp.server.experimental.task_scope (session scoping of task IDs) -""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/request_context.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/request_context.py deleted file mode 100644 index 9caad02..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/request_context.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Experimental request context features. - -This module provides the Experimental class which gives access to experimental -features within a request context, such as task-augmented request handling. - -WARNING: These APIs are experimental and may change without notice. -""" - -import warnings -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field -from typing import Any, overload - -from typing_extensions import deprecated - -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.server.experimental.task_scope import scoped_task_id -from mcp.server.experimental.task_support import TaskSupport -from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError -from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY, is_terminal -from mcp.types import ( - METHOD_NOT_FOUND, - TASK_FORBIDDEN, - TASK_REQUIRED, - ClientCapabilities, - CreateTaskResult, - ErrorData, - Result, - TaskExecutionMode, - TaskMetadata, - Tool, -) - -EXPLICIT_TASK_ID_DEPRECATION = ( - "Passing an explicit task_id to run_task is deprecated. A task created with an " - "explicit ID is not associated with the session that created it: any requestor " - "that presents the ID can read its status and result or cancel it, and it never " - "appears in tasks/list. Omit task_id to let the SDK generate an ID associated " - "with the creating session." -) - - -@dataclass -class Experimental: - """ - Experimental features context for task-augmented requests. - - Provides helpers for validating task execution compatibility and - running tasks with automatic lifecycle management. - - WARNING: This API is experimental and may change without notice. - """ - - task_metadata: TaskMetadata | None = None - _client_capabilities: ClientCapabilities | None = field(default=None, repr=False) - _session: ServerSession | None = field(default=None, repr=False) - _task_support: TaskSupport | None = field(default=None, repr=False) - - @property - def is_task(self) -> bool: - """Check if this request is task-augmented.""" - return self.task_metadata is not None - - @property - def client_supports_tasks(self) -> bool: - """Check if the client declared task support.""" - if self._client_capabilities is None: - return False - return self._client_capabilities.tasks is not None - - def validate_task_mode( - self, - tool_task_mode: TaskExecutionMode | None, - *, - raise_error: bool = True, - ) -> ErrorData | None: - """ - Validate that the request is compatible with the tool's task execution mode. - - Per MCP spec: - - "required": Clients MUST invoke as task. Server returns -32601 if not. - - "forbidden" (or None): Clients MUST NOT invoke as task. Server returns -32601 if they do. - - "optional": Either is acceptable. - - Args: - tool_task_mode: The tool's execution.taskSupport value - ("forbidden", "optional", "required", or None) - raise_error: If True, raises McpError on validation failure. If False, returns ErrorData. - - Returns: - None if valid, ErrorData if invalid and raise_error=False - - Raises: - McpError: If invalid and raise_error=True - """ - - mode = tool_task_mode or TASK_FORBIDDEN - - error: ErrorData | None = None - - if mode == TASK_REQUIRED and not self.is_task: - error = ErrorData( - code=METHOD_NOT_FOUND, - message="This tool requires task-augmented invocation", - ) - elif mode == TASK_FORBIDDEN and self.is_task: - error = ErrorData( - code=METHOD_NOT_FOUND, - message="This tool does not support task-augmented invocation", - ) - - if error is not None and raise_error: - raise McpError(error) - - return error - - def validate_for_tool( - self, - tool: Tool, - *, - raise_error: bool = True, - ) -> ErrorData | None: - """ - Validate that the request is compatible with the given tool. - - Convenience wrapper around validate_task_mode that extracts the mode from a Tool. - - Args: - tool: The Tool definition - raise_error: If True, raises McpError on validation failure. - - Returns: - None if valid, ErrorData if invalid and raise_error=False - """ - mode = tool.execution.taskSupport if tool.execution else None - return self.validate_task_mode(mode, raise_error=raise_error) - - def can_use_tool(self, tool_task_mode: TaskExecutionMode | None) -> bool: - """ - Check if this client can use a tool with the given task mode. - - Useful for filtering tool lists or providing warnings. - Returns False if tool requires "required" but client doesn't support tasks. - - Args: - tool_task_mode: The tool's execution.taskSupport value - - Returns: - True if the client can use this tool, False otherwise - """ - mode = tool_task_mode or TASK_FORBIDDEN - if mode == TASK_REQUIRED and not self.client_supports_tasks: - return False - return True - - @overload - async def run_task( - self, - work: Callable[[ServerTaskContext], Awaitable[Result]], - *, - task_id: None = None, - model_immediate_response: str | None = None, - ) -> CreateTaskResult: ... - - @overload - @deprecated(EXPLICIT_TASK_ID_DEPRECATION) - async def run_task( - self, - work: Callable[[ServerTaskContext], Awaitable[Result]], - *, - task_id: str, - model_immediate_response: str | None = None, - ) -> CreateTaskResult: ... - - async def run_task( - self, - work: Callable[[ServerTaskContext], Awaitable[Result]], - *, - task_id: str | None = None, - model_immediate_response: str | None = None, - ) -> CreateTaskResult: - """ - Create a task, spawn background work, and return CreateTaskResult immediately. - - This is the recommended way to handle task-augmented tool calls. It: - 1. Creates a task in the store - 2. Spawns the work function in a background task - 3. Returns CreateTaskResult immediately - - The work function receives a ServerTaskContext with: - - elicit() for sending elicitation requests - - create_message() for sampling requests - - update_status() for progress updates - - complete()/fail() for finishing the task - - When work() returns a Result, the task is auto-completed with that result. - If work() raises an exception, the task is auto-failed. - - Generated task IDs embed the session's task scope so that the default - task handlers only serve the task to the session that created it. An - explicitly provided `task_id` is used verbatim and is not associated - with the session, so any session can access it through the default - handlers; passing one is deprecated for that reason. - - Args: - work: Async function that does the actual work - task_id: Deprecated. Optional task ID, used verbatim and not - associated with the creating session. Omit it to let the SDK - generate one. - model_immediate_response: Optional string to include in _meta as - io.modelcontextprotocol/model-immediate-response - - Returns: - CreateTaskResult to return to the client - - Raises: - RuntimeError: If task support is not enabled or task_metadata is missing - - Example: - @server.call_tool() - async def handle_tool(name: str, args: dict): - ctx = server.request_context - - async def work(task: ServerTaskContext) -> CallToolResult: - result = await task.elicit( - message="Are you sure?", - requestedSchema={"type": "object", ...} - ) - confirmed = result.content.get("confirm", False) - return CallToolResult(content=[TextContent(text="Done" if confirmed else "Cancelled")]) - - return await ctx.experimental.run_task(work) - - WARNING: This API is experimental and may change without notice. - """ - if task_id is not None: - warnings.warn(EXPLICIT_TASK_ID_DEPRECATION, DeprecationWarning, stacklevel=2) - if self._task_support is None: - raise RuntimeError("Task support not enabled. Call server.experimental.enable_tasks() first.") - if self._session is None: - raise RuntimeError("Session not available.") - if self.task_metadata is None: - raise RuntimeError( - "Request is not task-augmented (no task field in params). " - "The client must send a task-augmented request." - ) - - support = self._task_support - # Access task_group via TaskSupport - raises if not in run() context - task_group = support.task_group - - if task_id is None: - features = self._session._experimental # pyright: ignore[reportPrivateUsage] - session_scope = features.task_session_scope - if session_scope is not None: - task_id = scoped_task_id(session_scope) - - task = await support.store.create_task(self.task_metadata, task_id) - - task_ctx = ServerTaskContext( - task=task, - store=support.store, - session=self._session, - queue=support.queue, - handler=support.handler, - ) - - async def execute() -> None: - try: - result = await work(task_ctx) - if not is_terminal(task_ctx.task.status): - await task_ctx.complete(result) - except Exception as e: - if not is_terminal(task_ctx.task.status): - await task_ctx.fail(str(e)) - - task_group.start_soon(execute) - - meta: dict[str, Any] | None = None - if model_immediate_response is not None: - meta = {MODEL_IMMEDIATE_RESPONSE_KEY: model_immediate_response} - - return CreateTaskResult(task=task, **{"_meta": meta} if meta else {}) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/session_features.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/session_features.py deleted file mode 100644 index c118537..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/session_features.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Experimental server session features for server→client task operations. - -This module provides the server-side equivalent of ExperimentalClientFeatures, -allowing the server to send task-augmented requests to the client and poll for results. - -WARNING: These APIs are experimental and may change without notice. -""" - -from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, TypeVar - -import mcp.types as types -from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages -from mcp.shared.experimental.tasks.capabilities import ( - require_task_augmented_elicitation, - require_task_augmented_sampling, -) -from mcp.shared.experimental.tasks.polling import poll_until_terminal - -if TYPE_CHECKING: - from mcp.server.session import ServerSession - -ResultT = TypeVar("ResultT", bound=types.Result) - - -class ExperimentalServerSessionFeatures: - """ - Experimental server session features for server→client task operations. - - This provides the server-side equivalent of ExperimentalClientFeatures, - allowing the server to send task-augmented requests to the client and - poll for results. - - WARNING: These APIs are experimental and may change without notice. - - Access via session.experimental: - result = await session.experimental.elicit_as_task(...) - """ - - def __init__(self, session: "ServerSession") -> None: - self._session = session - # Opaque marker identifying this session for task scoping. Assigned by - # TaskSupport.configure_session(). Task IDs generated by run_task() - # embed it so the default task handlers can restrict task access to - # the session that created the task. None means tasks created on this - # session are not associated with it (e.g. stateless servers). - self.task_session_scope: str | None = None - - async def get_task(self, task_id: str) -> types.GetTaskResult: - """ - Send tasks/get to the client to get task status. - - Args: - task_id: The task identifier - - Returns: - GetTaskResult containing the task status - """ - return await self._session.send_request( - types.ServerRequest(types.GetTaskRequest(params=types.GetTaskRequestParams(taskId=task_id))), - types.GetTaskResult, - ) - - async def get_task_result( - self, - task_id: str, - result_type: type[ResultT], - ) -> ResultT: - """ - Send tasks/result to the client to retrieve the final result. - - Args: - task_id: The task identifier - result_type: The expected result type - - Returns: - The task result, validated against result_type - """ - return await self._session.send_request( - types.ServerRequest(types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(taskId=task_id))), - result_type, - ) - - async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: - """ - Poll a client task until it reaches terminal status. - - Yields GetTaskResult for each poll, allowing the caller to react to - status changes. Exits when task reaches a terminal status. - - Respects the pollInterval hint from the client. - - Args: - task_id: The task identifier - - Yields: - GetTaskResult for each poll - """ - async for status in poll_until_terminal(self.get_task, task_id): - yield status - - async def elicit_as_task( - self, - message: str, - requestedSchema: types.ElicitRequestedSchema, - *, - ttl: int = 60000, - ) -> types.ElicitResult: - """ - Send a task-augmented elicitation to the client and poll until complete. - - The client will create a local task, process the elicitation asynchronously, - and return the result when ready. This method handles the full flow: - 1. Send elicitation with task field - 2. Receive CreateTaskResult from client - 3. Poll client's task until terminal - 4. Retrieve and return the final ElicitResult - - Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response - ttl: Task time-to-live in milliseconds - - Returns: - The client's elicitation response - - Raises: - McpError: If client doesn't support task-augmented elicitation - """ - client_caps = self._session.client_params.capabilities if self._session.client_params else None - require_task_augmented_elicitation(client_caps) - - create_result = await self._session.send_request( - types.ServerRequest( - types.ElicitRequest( - params=types.ElicitRequestFormParams( - message=message, - requestedSchema=requestedSchema, - task=types.TaskMetadata(ttl=ttl), - ) - ) - ), - types.CreateTaskResult, - ) - - task_id = create_result.task.taskId - - async for _ in self.poll_task(task_id): - pass - - return await self.get_task_result(task_id, types.ElicitResult) - - async def create_message_as_task( - self, - messages: list[types.SamplingMessage], - *, - max_tokens: int, - ttl: int = 60000, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - tools: list[types.Tool] | None = None, - tool_choice: types.ToolChoice | None = None, - ) -> types.CreateMessageResult: - """ - Send a task-augmented sampling request and poll until complete. - - The client will create a local task, process the sampling request - asynchronously, and return the result when ready. - - Args: - messages: The conversation messages for sampling - max_tokens: Maximum tokens in the response - ttl: Task time-to-live in milliseconds - system_prompt: Optional system prompt - include_context: Context inclusion strategy - temperature: Sampling temperature - stop_sequences: Stop sequences - metadata: Additional metadata - model_preferences: Model selection preferences - tools: Optional list of tools the LLM can use during sampling - tool_choice: Optional control over tool usage behavior - - Returns: - The sampling result from the client - - Raises: - McpError: If client doesn't support task-augmented sampling or tools - ValueError: If tool_use or tool_result message structure is invalid - """ - client_caps = self._session.client_params.capabilities if self._session.client_params else None - require_task_augmented_sampling(client_caps) - validate_sampling_tools(client_caps, tools, tool_choice) - validate_tool_use_result_messages(messages) - - create_result = await self._session.send_request( - types.ServerRequest( - types.CreateMessageRequest( - params=types.CreateMessageRequestParams( - messages=messages, - maxTokens=max_tokens, - systemPrompt=system_prompt, - includeContext=include_context, - temperature=temperature, - stopSequences=stop_sequences, - metadata=metadata, - modelPreferences=model_preferences, - tools=tools, - toolChoice=tool_choice, - task=types.TaskMetadata(ttl=ttl), - ) - ) - ), - types.CreateTaskResult, - ) - - task_id = create_result.task.taskId - - async for _ in self.poll_task(task_id): - pass - - return await self.get_task_result(task_id, types.CreateMessageResult) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_context.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_context.py deleted file mode 100644 index c9af82b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_context.py +++ /dev/null @@ -1,614 +0,0 @@ -""" -ServerTaskContext - Server-integrated task context with elicitation and sampling. - -This wraps the pure TaskContext and adds server-specific functionality: -- Elicitation (task.elicit()) -- Sampling (task.create_message()) -- Status notifications -""" - -from typing import Any - -import anyio - -from mcp.server.experimental.task_result_handler import TaskResultHandler -from mcp.server.session import ServerSession -from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages -from mcp.shared.exceptions import McpError -from mcp.shared.experimental.tasks.capabilities import ( - require_task_augmented_elicitation, - require_task_augmented_sampling, -) -from mcp.shared.experimental.tasks.context import TaskContext -from mcp.shared.experimental.tasks.message_queue import QueuedMessage, TaskMessageQueue -from mcp.shared.experimental.tasks.resolver import Resolver -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import ( - INVALID_REQUEST, - TASK_STATUS_INPUT_REQUIRED, - TASK_STATUS_WORKING, - ClientCapabilities, - CreateMessageResult, - CreateTaskResult, - ElicitationCapability, - ElicitRequestedSchema, - ElicitResult, - ErrorData, - IncludeContext, - ModelPreferences, - RequestId, - Result, - SamplingCapability, - SamplingMessage, - ServerNotification, - Task, - TaskMetadata, - TaskStatusNotification, - TaskStatusNotificationParams, - Tool, - ToolChoice, -) - - -class ServerTaskContext: - """ - Server-integrated task context with elicitation and sampling. - - This wraps a pure TaskContext and adds server-specific functionality: - - elicit() for sending elicitation requests to the client - - create_message() for sampling requests - - Status notifications via the session - - Example: - async def my_task_work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Starting...") - - result = await task.elicit( - message="Continue?", - requestedSchema={"type": "object", "properties": {"ok": {"type": "boolean"}}} - ) - - if result.content.get("ok"): - return CallToolResult(content=[TextContent(text="Done!")]) - else: - return CallToolResult(content=[TextContent(text="Cancelled")]) - """ - - def __init__( - self, - *, - task: Task, - store: TaskStore, - session: ServerSession, - queue: TaskMessageQueue, - handler: TaskResultHandler | None = None, - ): - """ - Create a ServerTaskContext. - - Args: - task: The Task object - store: The task store - session: The server session - queue: The message queue for elicitation/sampling - handler: The result handler for response routing (required for elicit/create_message) - """ - self._ctx = TaskContext(task=task, store=store) - self._session = session - self._queue = queue - self._handler = handler - self._store = store - - # Delegate pure properties to inner context - - @property - def task_id(self) -> str: - """The task identifier.""" - return self._ctx.task_id - - @property - def task(self) -> Task: - """The current task state.""" - return self._ctx.task - - @property - def is_cancelled(self) -> bool: - """Whether cancellation has been requested.""" - return self._ctx.is_cancelled - - def request_cancellation(self) -> None: - """Request cancellation of this task.""" - self._ctx.request_cancellation() - - # Enhanced methods with notifications - - async def update_status(self, message: str, *, notify: bool = True) -> None: - """ - Update the task's status message. - - Args: - message: The new status message - notify: Whether to send a notification to the client - """ - await self._ctx.update_status(message) - if notify: - await self._send_notification() - - async def complete(self, result: Result, *, notify: bool = True) -> None: - """ - Mark the task as completed with the given result. - - Args: - result: The task result - notify: Whether to send a notification to the client - """ - await self._ctx.complete(result) - if notify: - await self._send_notification() - - async def fail(self, error: str, *, notify: bool = True) -> None: - """ - Mark the task as failed with an error message. - - Args: - error: The error message - notify: Whether to send a notification to the client - """ - await self._ctx.fail(error) - if notify: - await self._send_notification() - - async def _send_notification(self) -> None: - """Send a task status notification to the client.""" - task = self._ctx.task - await self._session.send_notification( - ServerNotification( - TaskStatusNotification( - params=TaskStatusNotificationParams( - taskId=task.taskId, - status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, - ttl=task.ttl, - pollInterval=task.pollInterval, - ) - ) - ) - ) - - # Server-specific methods: elicitation and sampling - - def _check_elicitation_capability(self) -> None: - """Check if the client supports elicitation.""" - if not self._session.check_client_capability(ClientCapabilities(elicitation=ElicitationCapability())): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support elicitation capability", - ) - ) - - def _check_sampling_capability(self) -> None: - """Check if the client supports sampling.""" - if not self._session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support sampling capability", - ) - ) - - async def elicit( - self, - message: str, - requestedSchema: ElicitRequestedSchema, - ) -> ElicitResult: - """ - Send an elicitation request via the task message queue. - - This method: - 1. Checks client capability - 2. Updates task status to "input_required" - 3. Queues the elicitation request - 4. Waits for the response (delivered via tasks/result round-trip) - 5. Updates task status back to "working" - 6. Returns the result - - Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - - Returns: - The client's response - - Raises: - McpError: If client doesn't support elicitation capability - """ - self._check_elicitation_capability() - - if self._handler is None: - raise RuntimeError("handler is required for elicit(). Pass handler= to ServerTaskContext.") - - # Update status to input_required - await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) - - # Build the request using session's helper - request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage] - message=message, - requestedSchema=requestedSchema, - related_task_id=self.task_id, - ) - request_id: RequestId = request.id - - resolver: Resolver[dict[str, Any]] = Resolver() - self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] - - queued = QueuedMessage( - type="request", - message=request, - resolver=resolver, - original_request_id=request_id, - ) - await self._queue.enqueue(self.task_id, queued) - - try: - # Wait for response (routed back via TaskResultHandler) - response_data = await resolver.wait() - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - return ElicitResult.model_validate(response_data) - except anyio.get_cancelled_exc_class(): # pragma: no cover - # Coverage can't track async exception handlers reliably. - # This path is tested in test_elicit_restores_status_on_cancellation - # which verifies status is restored to "working" after cancellation. - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - raise - - async def elicit_url( - self, - message: str, - url: str, - elicitation_id: str, - ) -> ElicitResult: - """ - Send a URL mode elicitation request via the task message queue. - - This directs the user to an external URL for out-of-band interactions - like OAuth flows, credential collection, or payment processing. - - This method: - 1. Checks client capability - 2. Updates task status to "input_required" - 3. Queues the elicitation request - 4. Waits for the response (delivered via tasks/result round-trip) - 5. Updates task status back to "working" - 6. Returns the result - - Args: - message: Human-readable explanation of why the interaction is needed - url: The URL the user should navigate to - elicitation_id: Unique identifier for tracking this elicitation - - Returns: - The client's response indicating acceptance, decline, or cancellation - - Raises: - McpError: If client doesn't support elicitation capability - RuntimeError: If handler is not configured - """ - self._check_elicitation_capability() - - if self._handler is None: - raise RuntimeError("handler is required for elicit_url(). Pass handler= to ServerTaskContext.") - - # Update status to input_required - await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) - - # Build the request using session's helper - request = self._session._build_elicit_url_request( # pyright: ignore[reportPrivateUsage] - message=message, - url=url, - elicitation_id=elicitation_id, - related_task_id=self.task_id, - ) - request_id: RequestId = request.id - - resolver: Resolver[dict[str, Any]] = Resolver() - self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] - - queued = QueuedMessage( - type="request", - message=request, - resolver=resolver, - original_request_id=request_id, - ) - await self._queue.enqueue(self.task_id, queued) - - try: - # Wait for response (routed back via TaskResultHandler) - response_data = await resolver.wait() - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - return ElicitResult.model_validate(response_data) - except anyio.get_cancelled_exc_class(): # pragma: no cover - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - raise - - async def create_message( - self, - messages: list[SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: ModelPreferences | None = None, - tools: list[Tool] | None = None, - tool_choice: ToolChoice | None = None, - ) -> CreateMessageResult: - """ - Send a sampling request via the task message queue. - - This method: - 1. Checks client capability - 2. Updates task status to "input_required" - 3. Queues the sampling request - 4. Waits for the response (delivered via tasks/result round-trip) - 5. Updates task status back to "working" - 6. Returns the result - - Args: - messages: The conversation messages for sampling - max_tokens: Maximum tokens in the response - system_prompt: Optional system prompt - include_context: Context inclusion strategy - temperature: Sampling temperature - stop_sequences: Stop sequences - metadata: Additional metadata - model_preferences: Model selection preferences - tools: Optional list of tools the LLM can use during sampling - tool_choice: Optional control over tool usage behavior - - Returns: - The sampling result from the client - - Raises: - McpError: If client doesn't support sampling capability or tools - ValueError: If tool_use or tool_result message structure is invalid - """ - self._check_sampling_capability() - client_caps = self._session.client_params.capabilities if self._session.client_params else None - validate_sampling_tools(client_caps, tools, tool_choice) - validate_tool_use_result_messages(messages) - - if self._handler is None: - raise RuntimeError("handler is required for create_message(). Pass handler= to ServerTaskContext.") - - # Update status to input_required - await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) - - # Build the request using session's helper - request = self._session._build_create_message_request( # pyright: ignore[reportPrivateUsage] - messages=messages, - max_tokens=max_tokens, - system_prompt=system_prompt, - include_context=include_context, - temperature=temperature, - stop_sequences=stop_sequences, - metadata=metadata, - model_preferences=model_preferences, - tools=tools, - tool_choice=tool_choice, - related_task_id=self.task_id, - ) - request_id: RequestId = request.id - - resolver: Resolver[dict[str, Any]] = Resolver() - self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] - - queued = QueuedMessage( - type="request", - message=request, - resolver=resolver, - original_request_id=request_id, - ) - await self._queue.enqueue(self.task_id, queued) - - try: - # Wait for response (routed back via TaskResultHandler) - response_data = await resolver.wait() - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - return CreateMessageResult.model_validate(response_data) - except anyio.get_cancelled_exc_class(): # pragma: no cover - # Coverage can't track async exception handlers reliably. - # This path is tested in test_create_message_restores_status_on_cancellation - # which verifies status is restored to "working" after cancellation. - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - raise - - async def elicit_as_task( - self, - message: str, - requestedSchema: ElicitRequestedSchema, - *, - ttl: int = 60000, - ) -> ElicitResult: - """ - Send a task-augmented elicitation via the queue, then poll client. - - This is for use inside a task-augmented tool call when you want the client - to handle the elicitation as its own task. The elicitation request is queued - and delivered when the client calls tasks/result. After the client responds - with CreateTaskResult, we poll the client's task until complete. - - Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - ttl: Task time-to-live in milliseconds for the client's task - - Returns: - The client's elicitation response - - Raises: - McpError: If client doesn't support task-augmented elicitation - RuntimeError: If handler is not configured - """ - client_caps = self._session.client_params.capabilities if self._session.client_params else None - require_task_augmented_elicitation(client_caps) - - if self._handler is None: - raise RuntimeError("handler is required for elicit_as_task()") - - # Update status to input_required - await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) - - request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage] - message=message, - requestedSchema=requestedSchema, - related_task_id=self.task_id, - task=TaskMetadata(ttl=ttl), - ) - request_id: RequestId = request.id - - resolver: Resolver[dict[str, Any]] = Resolver() - self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] - - queued = QueuedMessage( - type="request", - message=request, - resolver=resolver, - original_request_id=request_id, - ) - await self._queue.enqueue(self.task_id, queued) - - try: - # Wait for initial response (CreateTaskResult from client) - response_data = await resolver.wait() - create_result = CreateTaskResult.model_validate(response_data) - client_task_id = create_result.task.taskId - - # Poll the client's task using the session's experimental features - features = self._session._experimental # pyright: ignore[reportPrivateUsage] - async for _ in features.poll_task(client_task_id): - pass - - # Get final result from client - result = await features.get_task_result( - client_task_id, - ElicitResult, - ) - - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - return result - - except anyio.get_cancelled_exc_class(): # pragma: no cover - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - raise - - async def create_message_as_task( - self, - messages: list[SamplingMessage], - *, - max_tokens: int, - ttl: int = 60000, - system_prompt: str | None = None, - include_context: IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: ModelPreferences | None = None, - tools: list[Tool] | None = None, - tool_choice: ToolChoice | None = None, - ) -> CreateMessageResult: - """ - Send a task-augmented sampling request via the queue, then poll client. - - This is for use inside a task-augmented tool call when you want the client - to handle the sampling as its own task. The request is queued and delivered - when the client calls tasks/result. After the client responds with - CreateTaskResult, we poll the client's task until complete. - - Args: - messages: The conversation messages for sampling - max_tokens: Maximum tokens in the response - ttl: Task time-to-live in milliseconds for the client's task - system_prompt: Optional system prompt - include_context: Context inclusion strategy - temperature: Sampling temperature - stop_sequences: Stop sequences - metadata: Additional metadata - model_preferences: Model selection preferences - tools: Optional list of tools the LLM can use during sampling - tool_choice: Optional control over tool usage behavior - - Returns: - The sampling result from the client - - Raises: - McpError: If client doesn't support task-augmented sampling or tools - ValueError: If tool_use or tool_result message structure is invalid - RuntimeError: If handler is not configured - """ - client_caps = self._session.client_params.capabilities if self._session.client_params else None - require_task_augmented_sampling(client_caps) - validate_sampling_tools(client_caps, tools, tool_choice) - validate_tool_use_result_messages(messages) - - if self._handler is None: - raise RuntimeError("handler is required for create_message_as_task()") - - # Update status to input_required - await self._store.update_task(self.task_id, status=TASK_STATUS_INPUT_REQUIRED) - - # Build request WITH task field for task-augmented sampling - request = self._session._build_create_message_request( # pyright: ignore[reportPrivateUsage] - messages=messages, - max_tokens=max_tokens, - system_prompt=system_prompt, - include_context=include_context, - temperature=temperature, - stop_sequences=stop_sequences, - metadata=metadata, - model_preferences=model_preferences, - tools=tools, - tool_choice=tool_choice, - related_task_id=self.task_id, - task=TaskMetadata(ttl=ttl), - ) - request_id: RequestId = request.id - - resolver: Resolver[dict[str, Any]] = Resolver() - self._handler._pending_requests[request_id] = resolver # pyright: ignore[reportPrivateUsage] - - queued = QueuedMessage( - type="request", - message=request, - resolver=resolver, - original_request_id=request_id, - ) - await self._queue.enqueue(self.task_id, queued) - - try: - # Wait for initial response (CreateTaskResult from client) - response_data = await resolver.wait() - create_result = CreateTaskResult.model_validate(response_data) - client_task_id = create_result.task.taskId - - # Poll the client's task using the session's experimental features - features = self._session._experimental # pyright: ignore[reportPrivateUsage] - async for _ in features.poll_task(client_task_id): - pass - - # Get final result from client - result = await features.get_task_result( - client_task_id, - CreateMessageResult, - ) - - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - return result - - except anyio.get_cancelled_exc_class(): # pragma: no cover - await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) - raise diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_result_handler.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_result_handler.py deleted file mode 100644 index 09f2a0a..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_result_handler.py +++ /dev/null @@ -1,243 +0,0 @@ -""" -TaskResultHandler - Integrated handler for tasks/result endpoint. - -This implements the dequeue-send-wait pattern from the MCP Tasks spec: -1. Dequeue all pending messages for the task -2. Send them to the client via transport with relatedRequestId routing -3. Wait if task is not in terminal state -4. Return final result when task completes - -This is the core of the task message queue pattern. -""" - -import logging -from typing import Any - -import anyio - -from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError -from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal -from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue -from mcp.shared.experimental.tasks.resolver import Resolver -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.shared.message import ServerMessageMetadata, SessionMessage -from mcp.types import ( - INVALID_PARAMS, - ErrorData, - GetTaskPayloadRequest, - GetTaskPayloadResult, - JSONRPCMessage, - RelatedTaskMetadata, - RequestId, -) - -logger = logging.getLogger(__name__) - - -class TaskResultHandler: - """ - Handler for tasks/result that implements the message queue pattern. - - This handler: - 1. Dequeues pending messages (elicitations, notifications) for the task - 2. Sends them to the client via the response stream - 3. Waits for responses and resolves them back to callers - 4. Blocks until task reaches terminal state - 5. Returns the final result - - Prefer `server.experimental.enable_tasks()`, whose default tasks/result - handler wraps `handle()` and only serves tasks created by the requesting - session. A custom handler that calls `handle()` directly is responsible - for deciding which requestors may access which tasks. - - Usage: - # Create handler with store and queue - handler = TaskResultHandler(task_store, message_queue) - - # Register it with the server - @server.experimental.get_task_result() - async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: - ctx = server.request_context - return await handler.handle(req, ctx.session, ctx.request_id) - """ - - def __init__( - self, - store: TaskStore, - queue: TaskMessageQueue, - ): - self._store = store - self._queue = queue - # Map from internal request ID to resolver for routing responses - self._pending_requests: dict[RequestId, Resolver[dict[str, Any]]] = {} - - async def send_message( - self, - session: ServerSession, - message: SessionMessage, - ) -> None: - """ - Send a message via the session. - - This is a helper for delivering queued task messages. - """ - await session.send_message(message) - - async def handle( - self, - request: GetTaskPayloadRequest, - session: ServerSession, - request_id: RequestId, - ) -> GetTaskPayloadResult: - """ - Handle a tasks/result request. - - This implements the dequeue-send-wait loop: - 1. Dequeue all pending messages - 2. Send each via transport with relatedRequestId = this request's ID - 3. If task not terminal, wait for status change - 4. Loop until task is terminal - 5. Return final result - - Args: - request: The GetTaskPayloadRequest - session: The server session for sending messages - request_id: The request ID for relatedRequestId routing - - Returns: - GetTaskPayloadResult with the task's final payload - """ - task_id = request.params.taskId - - while True: - task = await self._store.get_task(task_id) - if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {task_id}", - ) - ) - - await self._deliver_queued_messages(task_id, session, request_id) - - # If task is terminal, return result - if is_terminal(task.status): - result = await self._store.get_result(task_id) - # GetTaskPayloadResult is a Result with extra="allow" - # The stored result contains the actual payload data - # Per spec: tasks/result MUST include _meta with related-task metadata - related_task = RelatedTaskMetadata(taskId=task_id) - related_task_meta: dict[str, Any] = { - RELATED_TASK_METADATA_KEY: related_task.model_dump( - by_alias=True, - mode="json", - exclude_none=True, - ) - } - if result is not None: - result_data = result.model_dump(by_alias=True, mode="json", exclude_none=True) - existing_meta: dict[str, Any] = result_data.get("_meta") or {} - result_data["_meta"] = {**existing_meta, **related_task_meta} - return GetTaskPayloadResult.model_validate(result_data) - return GetTaskPayloadResult.model_validate({"_meta": related_task_meta}) - - # Wait for task update (status change or new messages) - await self._wait_for_task_update(task_id) - - async def _deliver_queued_messages( - self, - task_id: str, - session: ServerSession, - request_id: RequestId, - ) -> None: - """ - Dequeue and send all pending messages for a task. - - Each message is sent via the session's write stream with - relatedRequestId set so responses route back to this stream. - """ - while True: - message = await self._queue.dequeue(task_id) - if message is None: - break - - # If this is a request (not notification), wait for response - if message.type == "request" and message.resolver is not None: - # Store the resolver so we can route the response back - original_id = message.original_request_id - if original_id is not None: - self._pending_requests[original_id] = message.resolver - - logger.debug("Delivering queued message for task %s: %s", task_id, message.type) - - # Send the message with relatedRequestId for routing - session_message = SessionMessage( - message=JSONRPCMessage(message.message), - metadata=ServerMessageMetadata(related_request_id=request_id), - ) - await self.send_message(session, session_message) - - async def _wait_for_task_update(self, task_id: str) -> None: - """ - Wait for task to be updated (status change or new message). - - Races between store update and queue message - first one wins. - """ - async with anyio.create_task_group() as tg: - - async def wait_for_store() -> None: - try: - await self._store.wait_for_update(task_id) - except Exception: - pass - finally: - tg.cancel_scope.cancel() - - async def wait_for_queue() -> None: - try: - await self._queue.wait_for_message(task_id) - except Exception: - pass - finally: - tg.cancel_scope.cancel() - - tg.start_soon(wait_for_store) - tg.start_soon(wait_for_queue) - - def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: - """ - Route a response back to the waiting resolver. - - This is called when a response arrives for a queued request. - - Args: - request_id: The request ID from the response - response: The response data - - Returns: - True if response was routed, False if no pending request - """ - resolver = self._pending_requests.pop(request_id, None) - if resolver is not None and not resolver.done(): - resolver.set_result(response) - return True - return False - - def route_error(self, request_id: RequestId, error: ErrorData) -> bool: - """ - Route an error back to the waiting resolver. - - Args: - request_id: The request ID from the error response - error: The error data - - Returns: - True if error was routed, False if no pending request - """ - resolver = self._pending_requests.pop(request_id, None) - if resolver is not None and not resolver.done(): - resolver.set_exception(McpError(error)) - return True - return False diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_scope.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_scope.py deleted file mode 100644 index c33cf55..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_scope.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Session scoping for experimental task identifiers. - -Task IDs generated by `run_task()` embed an opaque, per-session marker (the -"session scope") so that the default task handlers can tell which session -created a task. The default handlers for tasks/get, tasks/result, tasks/list, -and tasks/cancel only operate on tasks created by the requesting session. - -Task IDs without a session scope (explicitly provided IDs, IDs created -directly through a TaskStore, or IDs created in stateless mode) have no known -creator. They can be used with tasks/get, tasks/result, and tasks/cancel from -any session - possession of the ID is what grants access - but they are never -included in tasks/list responses. - -WARNING: These APIs are experimental and may change without notice. -""" - -import re -from uuid import uuid4 - -__all__ = [ - "new_session_scope", - "scoped_task_id", - "session_scope_of", - "task_in_session_scope", - "task_listable_in_session_scope", -] - -# A scoped task ID has the form "<32 hex chars>:". Both halves must -# match exactly so that explicitly chosen task IDs are never mistaken for -# scoped ones. \Z rather than $ so a trailing newline cannot match. -_SCOPED_TASK_ID = re.compile( - r"\A(?P[0-9a-f]{32}):" - r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z" -) - - -def new_session_scope() -> str: - """Create a new opaque session scope token.""" - return uuid4().hex - - -def scoped_task_id(session_scope: str) -> str: - """Generate a task ID associated with the given session scope.""" - return f"{session_scope}:{uuid4()}" - - -def session_scope_of(task_id: str) -> str | None: - """Return the session scope embedded in a task ID, or None if it has none.""" - match = _SCOPED_TASK_ID.match(task_id) - return match.group("scope") if match else None - - -def task_in_session_scope(task_id: str, session_scope: str | None) -> bool: - """Whether a task may be used by a requestor with the given session scope. - - Used by tasks/get, tasks/result, and tasks/cancel. A task whose ID carries - no session scope has no known creator, so possession of the ID is what - grants access to it: it can be used from any session. - """ - embedded = session_scope_of(task_id) - return embedded is None or embedded == session_scope - - -def task_listable_in_session_scope(task_id: str, session_scope: str | None) -> bool: - """Whether a task may be included in a tasks/list response for the given session scope. - - Used by tasks/list. Listing is stricter than access by ID: a task is only - listed to the session that created it. Tasks with no session scope are - never listed because they have no known creator, and requestors with no - session scope are never shown any tasks because the server cannot tell - them apart. - """ - embedded = session_scope_of(task_id) - return embedded is not None and embedded == session_scope diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_support.py b/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_support.py deleted file mode 100644 index 80f4e1c..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/experimental/task_support.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -TaskSupport - Configuration for experimental task support. - -This module provides the TaskSupport class which encapsulates all the -infrastructure needed for task-augmented requests: store, queue, and handler. -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass, field - -import anyio -from anyio.abc import TaskGroup - -from mcp.server.experimental.task_result_handler import TaskResultHandler -from mcp.server.experimental.task_scope import new_session_scope -from mcp.server.session import ServerSession -from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore -from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue -from mcp.shared.experimental.tasks.store import TaskStore - - -@dataclass -class TaskSupport: - """ - Configuration for experimental task support. - - Encapsulates the task store, message queue, result handler, and task group - for spawning background work. - - When enabled on a server, this automatically: - - Configures response routing for each session - - Provides default handlers for task operations - - Manages a task group for background task execution - - Example: - # Simple in-memory setup - server.experimental.enable_tasks() - - # Custom store/queue for distributed systems - server.experimental.enable_tasks( - store=RedisTaskStore(redis_url), - queue=RedisTaskMessageQueue(redis_url), - ) - """ - - store: TaskStore - queue: TaskMessageQueue - handler: TaskResultHandler = field(init=False) - _task_group: TaskGroup | None = field(init=False, default=None) - - def __post_init__(self) -> None: - """Create the result handler from store and queue.""" - self.handler = TaskResultHandler(self.store, self.queue) - - @property - def task_group(self) -> TaskGroup: - """Get the task group for spawning background work. - - Raises: - RuntimeError: If not within a run() context - """ - if self._task_group is None: - raise RuntimeError("TaskSupport not running. Ensure Server.run() is active.") - return self._task_group - - @asynccontextmanager - async def run(self) -> AsyncIterator[None]: - """ - Run the task support lifecycle. - - This creates a task group for spawning background task work. - Called automatically by Server.run(). - - Usage: - async with task_support.run(): - # Task group is now available - ... - """ - async with anyio.create_task_group() as tg: - self._task_group = tg - try: - yield - finally: - self._task_group = None - - def configure_session(self, session: ServerSession, *, stateless: bool = False) -> None: - """ - Configure a session for task support. - - This registers the result handler as a response router so that - responses to queued requests (elicitation, sampling) are routed - back to the waiting resolvers. - - It also assigns the session a task session scope. Task IDs generated - by `run_task()` embed this scope, and the default task handlers only - operate on tasks created by the requesting session. Stateless sessions - are not assigned a scope: each request runs on a fresh session, so a - task created by one request could never be retrieved by a later one if - tasks were bound to the session that created them. - - Called automatically by Server.run() for each new session. - - Args: - session: The session to configure - stateless: Whether the session belongs to a stateless server run - """ - session.add_response_router(self.handler) - if not stateless: - features = session._experimental # pyright: ignore[reportPrivateUsage] - if features.task_session_scope is None: - features.task_session_scope = new_session_scope() - - @classmethod - def in_memory(cls) -> "TaskSupport": - """ - Create in-memory task support. - - Suitable for development, testing, and single-process servers. - For distributed systems, provide custom store and queue implementations. - - Returns: - TaskSupport configured with in-memory store and queue - """ - return cls( - store=InMemoryTaskStore(), - queue=InMemoryTaskMessageQueue(), - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/__init__.py deleted file mode 100644 index a89902c..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""FastMCP - A more ergonomic interface for MCP servers.""" - -from importlib.metadata import version - -from mcp.types import Icon - -from .server import Context, FastMCP -from .utilities.types import Audio, Image - -__version__ = version("mcp") -__all__ = ["FastMCP", "Context", "Image", "Audio", "Icon"] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/exceptions.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/exceptions.py deleted file mode 100644 index fb5bda1..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/exceptions.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Custom exceptions for FastMCP.""" - - -class FastMCPError(Exception): - """Base error for FastMCP.""" - - -class ValidationError(FastMCPError): - """Error in validating parameters or return values.""" - - -class ResourceError(FastMCPError): - """Error in resource operations.""" - - -class ToolError(FastMCPError): - """Error in tool operations.""" - - -class InvalidSignature(Exception): - """Invalid signature for use with FastMCP.""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/__init__.py deleted file mode 100644 index 7637269..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .base import Prompt -from .manager import PromptManager - -__all__ = ["Prompt", "PromptManager"] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/base.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/base.py deleted file mode 100644 index 48c65b5..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/base.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Base classes for FastMCP prompts.""" - -from __future__ import annotations - -import inspect -from collections.abc import Awaitable, Callable, Sequence -from typing import TYPE_CHECKING, Any, Literal - -import pydantic_core -from pydantic import BaseModel, Field, TypeAdapter, validate_call - -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context -from mcp.server.fastmcp.utilities.func_metadata import func_metadata -from mcp.types import ContentBlock, Icon, TextContent - -if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT - - -class Message(BaseModel): - """Base class for all prompt messages.""" - - role: Literal["user", "assistant"] - content: ContentBlock - - def __init__(self, content: str | ContentBlock, **kwargs: Any): - if isinstance(content, str): - content = TextContent(type="text", text=content) - super().__init__(content=content, **kwargs) - - -class UserMessage(Message): - """A message from the user.""" - - role: Literal["user", "assistant"] = "user" - - def __init__(self, content: str | ContentBlock, **kwargs: Any): - super().__init__(content=content, **kwargs) - - -class AssistantMessage(Message): - """A message from the assistant.""" - - role: Literal["user", "assistant"] = "assistant" - - def __init__(self, content: str | ContentBlock, **kwargs: Any): - super().__init__(content=content, **kwargs) - - -message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage) - -SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] -PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] - - -class PromptArgument(BaseModel): - """An argument that can be passed to a prompt.""" - - name: str = Field(description="Name of the argument") - description: str | None = Field(None, description="Description of what the argument does") - required: bool = Field(default=False, description="Whether the argument is required") - - -class Prompt(BaseModel): - """A prompt template that can be rendered with parameters.""" - - name: str = Field(description="Name of the prompt") - title: str | None = Field(None, description="Human-readable title of the prompt") - description: str | None = Field(None, description="Description of what the prompt does") - arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt") - fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True) - icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this prompt") - context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context", exclude=True) - - @classmethod - def from_function( - cls, - fn: Callable[..., PromptResult | Awaitable[PromptResult]], - name: str | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - context_kwarg: str | None = None, - ) -> Prompt: - """Create a Prompt from a function. - - The function can return: - - A string (converted to a message) - - A Message object - - A dict (converted to a message) - - A sequence of any of the above - """ - func_name = name or fn.__name__ - - if func_name == "": # pragma: no cover - raise ValueError("You must provide a name for lambda functions") - - # Find context parameter if it exists - if context_kwarg is None: # pragma: no branch - context_kwarg = find_context_parameter(fn) - - # Get schema from func_metadata, excluding context parameter - func_arg_metadata = func_metadata( - fn, - skip_names=[context_kwarg] if context_kwarg is not None else [], - ) - parameters = func_arg_metadata.arg_model.model_json_schema() - - # Convert parameters to PromptArguments - arguments: list[PromptArgument] = [] - if "properties" in parameters: # pragma: no branch - for param_name, param in parameters["properties"].items(): - required = param_name in parameters.get("required", []) - arguments.append( - PromptArgument( - name=param_name, - description=param.get("description"), - required=required, - ) - ) - - # ensure the arguments are properly cast - fn = validate_call(fn) - - return cls( - name=func_name, - title=title, - description=description or fn.__doc__ or "", - arguments=arguments, - fn=fn, - icons=icons, - context_kwarg=context_kwarg, - ) - - async def render( - self, - arguments: dict[str, Any] | None = None, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> list[Message]: - """Render the prompt with arguments.""" - # Validate required arguments - if self.arguments: - required = {arg.name for arg in self.arguments if arg.required} - provided = set(arguments or {}) - missing = required - provided - if missing: - raise ValueError(f"Missing required arguments: {missing}") - - try: - # Add context to arguments if needed - call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg) - - # Call function and check if result is a coroutine - result = self.fn(**call_args) - if inspect.iscoroutine(result): - result = await result - - # Validate messages - if not isinstance(result, list | tuple): - result = [result] - - # Convert result to messages - messages: list[Message] = [] - for msg in result: # type: ignore[reportUnknownVariableType] - try: - if isinstance(msg, Message): - messages.append(msg) - elif isinstance(msg, dict): - messages.append(message_validator.validate_python(msg)) - elif isinstance(msg, str): - content = TextContent(type="text", text=msg) - messages.append(UserMessage(content=content)) - else: # pragma: no cover - content = pydantic_core.to_json(msg, fallback=str, indent=2).decode() - messages.append(Message(role="user", content=content)) - except Exception: # pragma: no cover - raise ValueError(f"Could not convert prompt result to message: {msg}") - - return messages - except Exception as e: # pragma: no cover - raise ValueError(f"Error rendering prompt {self.name}: {e}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/manager.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/manager.py deleted file mode 100644 index 6d032c7..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/prompts/manager.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Prompt management functionality.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from mcp.server.fastmcp.prompts.base import Message, Prompt -from mcp.server.fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT - -logger = get_logger(__name__) - - -class PromptManager: - """Manages FastMCP prompts.""" - - def __init__(self, warn_on_duplicate_prompts: bool = True): - self._prompts: dict[str, Prompt] = {} - self.warn_on_duplicate_prompts = warn_on_duplicate_prompts - - def get_prompt(self, name: str) -> Prompt | None: - """Get prompt by name.""" - return self._prompts.get(name) - - def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" - return list(self._prompts.values()) - - def add_prompt( - self, - prompt: Prompt, - ) -> Prompt: - """Add a prompt to the manager.""" - - # Check for duplicates - existing = self._prompts.get(prompt.name) - if existing: - if self.warn_on_duplicate_prompts: - logger.warning(f"Prompt already exists: {prompt.name}") - return existing - - self._prompts[prompt.name] = prompt - return prompt - - async def render_prompt( - self, - name: str, - arguments: dict[str, Any] | None = None, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> list[Message]: - """Render a prompt by name with arguments.""" - prompt = self.get_prompt(name) - if not prompt: - raise ValueError(f"Unknown prompt: {name}") - - return await prompt.render(arguments, context=context) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/__init__.py deleted file mode 100644 index b5805fb..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -from .base import Resource -from .resource_manager import ResourceManager -from .templates import ResourceTemplate -from .types import ( - BinaryResource, - DirectoryResource, - FileResource, - FunctionResource, - HttpResource, - TextResource, -) - -__all__ = [ - "Resource", - "TextResource", - "BinaryResource", - "FunctionResource", - "FileResource", - "HttpResource", - "DirectoryResource", - "ResourceTemplate", - "ResourceManager", -] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/base.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/base.py deleted file mode 100644 index e34b97a..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/base.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Base classes and interfaces for FastMCP resources.""" - -import abc -from typing import Annotated, Any - -from pydantic import ( - AnyUrl, - BaseModel, - ConfigDict, - Field, - UrlConstraints, - ValidationInfo, - field_validator, -) - -from mcp.types import Annotations, Icon - - -class Resource(BaseModel, abc.ABC): - """Base class for all resources.""" - - model_config = ConfigDict(validate_default=True) - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource") - name: str | None = Field(description="Name of the resource", default=None) - title: str | None = Field(description="Human-readable title of the resource", default=None) - description: str | None = Field(description="Description of the resource", default=None) - mime_type: str = Field( - default="text/plain", - description="MIME type of the resource content", - pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$", - ) - icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource") - annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource") - meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource") - - @field_validator("name", mode="before") - @classmethod - def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: - """Set default name from URI if not provided.""" - if name: - return name - if uri := info.data.get("uri"): - return str(uri) - raise ValueError("Either name or uri must be provided") - - @abc.abstractmethod - async def read(self) -> str | bytes: - """Read the resource content.""" - pass # pragma: no cover diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/resource_manager.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/resource_manager.py deleted file mode 100644 index 20f67bb..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/resource_manager.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Resource manager functionality.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from pydantic import AnyUrl - -from mcp.server.fastmcp.resources.base import Resource -from mcp.server.fastmcp.resources.templates import ResourceTemplate -from mcp.server.fastmcp.utilities.logging import get_logger -from mcp.types import Annotations, Icon - -if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT - -logger = get_logger(__name__) - - -class ResourceManager: - """Manages FastMCP resources.""" - - def __init__(self, warn_on_duplicate_resources: bool = True): - self._resources: dict[str, Resource] = {} - self._templates: dict[str, ResourceTemplate] = {} - self.warn_on_duplicate_resources = warn_on_duplicate_resources - - def add_resource(self, resource: Resource) -> Resource: - """Add a resource to the manager. - - Args: - resource: A Resource instance to add - - Returns: - The added resource. If a resource with the same URI already exists, - returns the existing resource. - """ - logger.debug( - "Adding resource", - extra={ - "uri": resource.uri, - "type": type(resource).__name__, - "resource_name": resource.name, - }, - ) - existing = self._resources.get(str(resource.uri)) - if existing: - if self.warn_on_duplicate_resources: - logger.warning(f"Resource already exists: {resource.uri}") - return existing - self._resources[str(resource.uri)] = resource - return resource - - def add_template( - self, - fn: Callable[..., Any], - uri_template: str, - name: str | None = None, - title: str | None = None, - description: str | None = None, - mime_type: str | None = None, - icons: list[Icon] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - ) -> ResourceTemplate: - """Add a template from a function.""" - template = ResourceTemplate.from_function( - fn, - uri_template=uri_template, - name=name, - title=title, - description=description, - mime_type=mime_type, - icons=icons, - annotations=annotations, - meta=meta, - ) - self._templates[template.uri_template] = template - return template - - async def get_resource( - self, - uri: AnyUrl | str, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> Resource | None: - """Get resource by URI, checking concrete resources first, then templates.""" - uri_str = str(uri) - logger.debug("Getting resource", extra={"uri": uri_str}) - - # First check concrete resources - if resource := self._resources.get(uri_str): - return resource - - # Then check templates - for template in self._templates.values(): - if params := template.matches(uri_str): - try: - return await template.create_resource(uri_str, params, context=context) - except Exception as e: # pragma: no cover - raise ValueError(f"Error creating resource from template: {e}") - - raise ValueError(f"Unknown resource: {uri}") - - def list_resources(self) -> list[Resource]: - """List all registered resources.""" - logger.debug("Listing resources", extra={"count": len(self._resources)}) - return list(self._resources.values()) - - def list_templates(self) -> list[ResourceTemplate]: - """List all registered templates.""" - logger.debug("Listing templates", extra={"count": len(self._templates)}) - return list(self._templates.values()) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/templates.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/templates.py deleted file mode 100644 index 89a8ceb..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/templates.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Resource template functionality.""" - -from __future__ import annotations - -import inspect -import re -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from pydantic import BaseModel, Field, validate_call - -from mcp.server.fastmcp.resources.types import FunctionResource, Resource -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context -from mcp.server.fastmcp.utilities.func_metadata import func_metadata -from mcp.types import Annotations, Icon - -if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT - - -class ResourceTemplate(BaseModel): - """A template for dynamically creating resources.""" - - uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)") - name: str = Field(description="Name of the resource") - title: str | None = Field(description="Human-readable title of the resource", default=None) - description: str | None = Field(description="Description of what the resource does") - mime_type: str = Field(default="text/plain", description="MIME type of the resource content") - icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template") - annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template") - meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template") - fn: Callable[..., Any] = Field(exclude=True) - parameters: dict[str, Any] = Field(description="JSON schema for function parameters") - context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - uri_template: str, - name: str | None = None, - title: str | None = None, - description: str | None = None, - mime_type: str | None = None, - icons: list[Icon] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - context_kwarg: str | None = None, - ) -> ResourceTemplate: - """Create a template from a function.""" - func_name = name or fn.__name__ - if func_name == "": - raise ValueError("You must provide a name for lambda functions") # pragma: no cover - - # Find context parameter if it exists - if context_kwarg is None: # pragma: no branch - context_kwarg = find_context_parameter(fn) - - # Get schema from func_metadata, excluding context parameter - func_arg_metadata = func_metadata( - fn, - skip_names=[context_kwarg] if context_kwarg is not None else [], - ) - parameters = func_arg_metadata.arg_model.model_json_schema() - - # ensure the arguments are properly cast - fn = validate_call(fn) - - return cls( - uri_template=uri_template, - name=func_name, - title=title, - description=description or fn.__doc__ or "", - mime_type=mime_type or "text/plain", - icons=icons, - annotations=annotations, - meta=meta, - fn=fn, - parameters=parameters, - context_kwarg=context_kwarg, - ) - - def matches(self, uri: str) -> dict[str, Any] | None: - """Check if URI matches template and extract parameters.""" - # Convert template to regex pattern - pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") - match = re.match(f"^{pattern}$", uri) - if match: - return match.groupdict() - return None - - async def create_resource( - self, - uri: str, - params: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> Resource: - """Create a resource from the template with the given parameters.""" - try: - # Add context to params if needed - params = inject_context(self.fn, params, context, self.context_kwarg) - - # Call function and check if result is a coroutine - result = self.fn(**params) - if inspect.iscoroutine(result): - result = await result - - return FunctionResource( - uri=uri, # type: ignore - name=self.name, - title=self.title, - description=self.description, - mime_type=self.mime_type, - icons=self.icons, - annotations=self.annotations, - meta=self.meta, - fn=lambda: result, # Capture result in closure - ) - except Exception as e: - raise ValueError(f"Error creating resource from template: {e}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/types.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/types.py deleted file mode 100644 index 5f72430..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/resources/types.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Concrete resource implementations.""" - -import inspect -import json -from collections.abc import Callable -from pathlib import Path -from typing import Any - -import anyio -import anyio.to_thread -import httpx -import pydantic -import pydantic_core -from pydantic import AnyUrl, Field, ValidationInfo, validate_call - -from mcp.server.fastmcp.resources.base import Resource -from mcp.types import Annotations, Icon - - -class TextResource(Resource): - """A resource that reads from a string.""" - - text: str = Field(description="Text content of the resource") - - async def read(self) -> str: - """Read the text content.""" - return self.text # pragma: no cover - - -class BinaryResource(Resource): - """A resource that reads from bytes.""" - - data: bytes = Field(description="Binary content of the resource") - - async def read(self) -> bytes: - """Read the binary content.""" - return self.data # pragma: no cover - - -class FunctionResource(Resource): - """A resource that defers data loading by wrapping a function. - - The function is only called when the resource is read, allowing for lazy loading - of potentially expensive data. This is particularly useful when listing resources, - as the function won't be called until the resource is actually accessed. - - The function can return: - - str for text content (default) - - bytes for binary content - - other types will be converted to JSON - """ - - fn: Callable[[], Any] = Field(exclude=True) - - async def read(self) -> str | bytes: - """Read the resource by calling the wrapped function.""" - try: - # Call the function first to see if it returns a coroutine - result = self.fn() - # If it's a coroutine, await it - if inspect.iscoroutine(result): - result = await result - - if isinstance(result, Resource): # pragma: no cover - return await result.read() - elif isinstance(result, bytes): - return result - elif isinstance(result, str): - return result - else: - return pydantic_core.to_json(result, fallback=str, indent=2).decode() - except Exception as e: - raise ValueError(f"Error reading resource {self.uri}: {e}") - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - uri: str, - name: str | None = None, - title: str | None = None, - description: str | None = None, - mime_type: str | None = None, - icons: list[Icon] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - ) -> "FunctionResource": - """Create a FunctionResource from a function.""" - func_name = name or fn.__name__ - if func_name == "": # pragma: no cover - raise ValueError("You must provide a name for lambda functions") - - # ensure the arguments are properly cast - fn = validate_call(fn) - - return cls( - uri=AnyUrl(uri), - name=func_name, - title=title, - description=description or fn.__doc__ or "", - mime_type=mime_type or "text/plain", - fn=fn, - icons=icons, - annotations=annotations, - meta=meta, - ) - - -class FileResource(Resource): - """A resource that reads from a file. - - Set is_binary=True to read file as binary data instead of text. - """ - - path: Path = Field(description="Path to the file") - is_binary: bool = Field( - default=False, - description="Whether to read the file as binary data", - ) - mime_type: str = Field( - default="text/plain", - description="MIME type of the resource content", - ) - - @pydantic.field_validator("path") - @classmethod - def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover - """Ensure path is absolute.""" - if not path.is_absolute(): - raise ValueError("Path must be absolute") - return path - - @pydantic.field_validator("is_binary") - @classmethod - def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: - """Set is_binary based on mime_type if not explicitly set.""" - if is_binary: - return True - mime_type = info.data.get("mime_type", "text/plain") - return not mime_type.startswith("text/") - - async def read(self) -> str | bytes: - """Read the file content.""" - try: - if self.is_binary: - return await anyio.to_thread.run_sync(self.path.read_bytes) - return await anyio.to_thread.run_sync(self.path.read_text) - except Exception as e: - raise ValueError(f"Error reading file {self.path}: {e}") - - -class HttpResource(Resource): - """A resource that reads from an HTTP endpoint.""" - - url: str = Field(description="URL to fetch content from") - mime_type: str = Field(default="application/json", description="MIME type of the resource content") - - async def read(self) -> str | bytes: - """Read the HTTP content.""" - async with httpx.AsyncClient() as client: # pragma: no cover - response = await client.get(self.url) - response.raise_for_status() - return response.text - - -class DirectoryResource(Resource): - """A resource that lists files in a directory.""" - - path: Path = Field(description="Path to the directory") - recursive: bool = Field(default=False, description="Whether to list files recursively") - pattern: str | None = Field(default=None, description="Optional glob pattern to filter files") - mime_type: str = Field(default="application/json", description="MIME type of the resource content") - - @pydantic.field_validator("path") - @classmethod - def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover - """Ensure path is absolute.""" - if not path.is_absolute(): - raise ValueError("Path must be absolute") - return path - - def list_files(self) -> list[Path]: # pragma: no cover - """List files in the directory.""" - if not self.path.exists(): - raise FileNotFoundError(f"Directory not found: {self.path}") - if not self.path.is_dir(): - raise NotADirectoryError(f"Not a directory: {self.path}") - - try: - if self.pattern: - return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern)) - return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*")) - except Exception as e: - raise ValueError(f"Error listing directory {self.path}: {e}") - - async def read(self) -> str: # Always returns JSON string # pragma: no cover - """Read the directory listing.""" - try: - files = await anyio.to_thread.run_sync(self.list_files) - file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] - return json.dumps({"files": file_list}, indent=2) - except Exception as e: - raise ValueError(f"Error reading directory {self.path}: {e}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/server.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/server.py deleted file mode 100644 index 8f62ce2..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/server.py +++ /dev/null @@ -1,1354 +0,0 @@ -"""FastMCP - A more ergonomic interface for MCP servers.""" - -from __future__ import annotations as _annotations - -import inspect -import re -from collections.abc import ( - AsyncIterator, - Awaitable, - Callable, - Collection, - Iterable, - Sequence, -) -from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import Any, Generic, Literal - -import anyio -import pydantic_core -from pydantic import BaseModel -from pydantic.networks import AnyUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import Mount, Route -from starlette.types import Receive, Scope, Send - -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import ( - BearerAuthBackend, - RequireAuthMiddleware, -) -from mcp.server.auth.provider import ( - OAuthAuthorizationServerProvider, - ProviderTokenVerifier, - TokenVerifier, -) -from mcp.server.auth.settings import AuthSettings -from mcp.server.elicitation import ( - ElicitationResult, - ElicitSchemaModelT, - UrlElicitationResult, - elicit_with_validation, -) -from mcp.server.elicitation import ( - elicit_url as _elicit_url, -) -from mcp.server.fastmcp.exceptions import ResourceError -from mcp.server.fastmcp.prompts import Prompt, PromptManager -from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager -from mcp.server.fastmcp.tools import Tool, ToolManager -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter -from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger -from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.lowlevel.server import LifespanResultT -from mcp.server.lowlevel.server import Server as MCPServer -from mcp.server.lowlevel.server import lifespan as default_lifespan -from mcp.server.session import ServerSession, ServerSessionT -from mcp.server.sse import SseServerTransport -from mcp.server.stdio import stdio_server -from mcp.server.streamable_http import EventStore -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.context import LifespanContextT, RequestContext, RequestT -from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations -from mcp.types import Prompt as MCPPrompt -from mcp.types import PromptArgument as MCPPromptArgument -from mcp.types import Resource as MCPResource -from mcp.types import ResourceTemplate as MCPResourceTemplate -from mcp.types import Tool as MCPTool - -logger = get_logger(__name__) - - -class Settings(BaseSettings, Generic[LifespanResultT]): - """FastMCP server settings. - - All settings can be configured via environment variables with the prefix FASTMCP_. - For example, FASTMCP_DEBUG=true will set debug=True. - """ - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_", - env_file=".env", - env_nested_delimiter="__", - nested_model_default_partial_update=True, - extra="ignore", - ) - - # Server settings - debug: bool - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - - # HTTP settings - host: str - port: int - mount_path: str - sse_path: str - message_path: str - streamable_http_path: str - - # StreamableHTTP settings - json_response: bool - stateless_http: bool - """Define if the server should create a new transport per request.""" - - # resource settings - warn_on_duplicate_resources: bool - - # tool settings - warn_on_duplicate_tools: bool - - # prompt settings - warn_on_duplicate_prompts: bool - - # TODO(Marcelo): Investigate if this is used. If it is, it's probably a good idea to remove it. - dependencies: list[str] - """A list of dependencies to install in the server environment.""" - - lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None - """A async context manager that will be called when the server is started.""" - - auth: AuthSettings | None - - # Transport security settings (DNS rebinding protection) - transport_security: TransportSecuritySettings | None - - -def lifespan_wrapper( - app: FastMCP[LifespanResultT], - lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], -) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[LifespanResultT]]: - @asynccontextmanager - async def wrap( - _: MCPServer[LifespanResultT, Request], - ) -> AsyncIterator[LifespanResultT]: - async with lifespan(app) as context: - yield context - - return wrap - - -class FastMCP(Generic[LifespanResultT]): - def __init__( # noqa: PLR0913 - self, - name: str | None = None, - instructions: str | None = None, - website_url: str | None = None, - icons: list[Icon] | None = None, - auth_server_provider: (OAuthAuthorizationServerProvider[Any, Any, Any] | None) = None, - token_verifier: TokenVerifier | None = None, - event_store: EventStore | None = None, - retry_interval: int | None = None, - *, - tools: list[Tool] | None = None, - debug: bool = False, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", - host: str = "127.0.0.1", - port: int = 8000, - mount_path: str = "/", - sse_path: str = "/sse", - message_path: str = "/messages/", - streamable_http_path: str = "/mcp", - json_response: bool = False, - stateless_http: bool = False, - warn_on_duplicate_resources: bool = True, - warn_on_duplicate_tools: bool = True, - warn_on_duplicate_prompts: bool = True, - dependencies: Collection[str] = (), - lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None, - auth: AuthSettings | None = None, - transport_security: TransportSecuritySettings | None = None, - ): - # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) - if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): - transport_security = TransportSecuritySettings( - enable_dns_rebinding_protection=True, - allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], - allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], - ) - - self.settings = Settings( - debug=debug, - log_level=log_level, - host=host, - port=port, - mount_path=mount_path, - sse_path=sse_path, - message_path=message_path, - streamable_http_path=streamable_http_path, - json_response=json_response, - stateless_http=stateless_http, - warn_on_duplicate_resources=warn_on_duplicate_resources, - warn_on_duplicate_tools=warn_on_duplicate_tools, - warn_on_duplicate_prompts=warn_on_duplicate_prompts, - dependencies=list(dependencies), - lifespan=lifespan, - auth=auth, - transport_security=transport_security, - ) - - self._mcp_server = MCPServer( - name=name or "FastMCP", - instructions=instructions, - website_url=website_url, - icons=icons, - # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an FastMCP and Server. - # We need to create a Lifespan type that is a generic on the server type, like Starlette does. - lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore - ) - self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools) - self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources) - self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) - # Validate auth configuration - if self.settings.auth is not None: - if auth_server_provider and token_verifier: # pragma: no cover - raise ValueError("Cannot specify both auth_server_provider and token_verifier") - if not auth_server_provider and not token_verifier: # pragma: no cover - raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled") - elif auth_server_provider or token_verifier: # pragma: no cover - raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings") - - self._auth_server_provider = auth_server_provider - self._token_verifier = token_verifier - - # Create token verifier from provider if needed (backwards compatibility) - if auth_server_provider and not token_verifier: # pragma: no cover - self._token_verifier = ProviderTokenVerifier(auth_server_provider) - self._event_store = event_store - self._retry_interval = retry_interval - self._custom_starlette_routes: list[Route] = [] - self.dependencies = self.settings.dependencies - self._session_manager: StreamableHTTPSessionManager | None = None - - # Set up MCP protocol handlers - self._setup_handlers() - - # Configure logging - configure_logging(self.settings.log_level) - - @property - def name(self) -> str: - return self._mcp_server.name - - @property - def instructions(self) -> str | None: - return self._mcp_server.instructions - - @property - def website_url(self) -> str | None: - return self._mcp_server.website_url - - @property - def icons(self) -> list[Icon] | None: - return self._mcp_server.icons - - @property - def session_manager(self) -> StreamableHTTPSessionManager: - """Get the StreamableHTTP session manager. - - This is exposed to enable advanced use cases like mounting multiple - FastMCP servers in a single FastAPI application. - - Raises: - RuntimeError: If called before streamable_http_app() has been called. - """ - if self._session_manager is None: # pragma: no cover - raise RuntimeError( - "Session manager can only be accessed after" - "calling streamable_http_app()." - "The session manager is created lazily" - "to avoid unnecessary initialization." - ) - return self._session_manager # pragma: no cover - - def run( - self, - transport: Literal["stdio", "sse", "streamable-http"] = "stdio", - mount_path: str | None = None, - ) -> None: - """Run the FastMCP server. Note this is a synchronous function. - - Args: - transport: Transport protocol to use ("stdio", "sse", or "streamable-http") - mount_path: Optional mount path for SSE transport - """ - TRANSPORTS = Literal["stdio", "sse", "streamable-http"] - if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover - raise ValueError(f"Unknown transport: {transport}") - - match transport: - case "stdio": - anyio.run(self.run_stdio_async) - case "sse": # pragma: no cover - anyio.run(lambda: self.run_sse_async(mount_path)) - case "streamable-http": # pragma: no cover - anyio.run(self.run_streamable_http_async) - - def _setup_handlers(self) -> None: - """Set up core MCP protocol handlers.""" - self._mcp_server.list_tools()(self.list_tools) - # Note: we disable the lowlevel server's input validation. - # FastMCP does ad hoc conversion of incoming data before validating - - # for now we preserve this for backwards compatibility. - self._mcp_server.call_tool(validate_input=False)(self.call_tool) - self._mcp_server.list_resources()(self.list_resources) - self._mcp_server.read_resource()(self.read_resource) - self._mcp_server.list_prompts()(self.list_prompts) - self._mcp_server.get_prompt()(self.get_prompt) - self._mcp_server.list_resource_templates()(self.list_resource_templates) - - async def list_tools(self) -> list[MCPTool]: - """List all available tools.""" - tools = self._tool_manager.list_tools() - return [ - MCPTool( - name=info.name, - title=info.title, - description=info.description, - inputSchema=info.parameters, - outputSchema=info.output_schema, - annotations=info.annotations, - icons=info.icons, - _meta=info.meta, - ) - for info in tools - ] - - def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: - """ - Returns a Context object. Note that the context will only be valid - during a request; outside a request, most methods will error. - """ - try: - request_context = self._mcp_server.request_context - except LookupError: - request_context = None - return Context(request_context=request_context, fastmcp=self) - - async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]: - """Call a tool by name with arguments.""" - context = self.get_context() - return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True) - - async def list_resources(self) -> list[MCPResource]: - """List all available resources.""" - - resources = self._resource_manager.list_resources() - return [ - MCPResource( - uri=resource.uri, - name=resource.name or "", - title=resource.title, - description=resource.description, - mimeType=resource.mime_type, - icons=resource.icons, - annotations=resource.annotations, - _meta=resource.meta, - ) - for resource in resources - ] - - async def list_resource_templates(self) -> list[MCPResourceTemplate]: - templates = self._resource_manager.list_templates() - return [ - MCPResourceTemplate( - uriTemplate=template.uri_template, - name=template.name, - title=template.title, - description=template.description, - mimeType=template.mime_type, - icons=template.icons, - annotations=template.annotations, - _meta=template.meta, - ) - for template in templates - ] - - async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]: - """Read a resource by URI.""" - - context = self.get_context() - resource = await self._resource_manager.get_resource(uri, context=context) - if not resource: # pragma: no cover - raise ResourceError(f"Unknown resource: {uri}") - - try: - content = await resource.read() - return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] - except Exception as e: # pragma: no cover - logger.exception(f"Error reading resource {uri}") - raise ResourceError(str(e)) - - def add_tool( - self, - fn: AnyFunction, - name: str | None = None, - title: str | None = None, - description: str | None = None, - annotations: ToolAnnotations | None = None, - icons: list[Icon] | None = None, - meta: dict[str, Any] | None = None, - structured_output: bool | None = None, - ) -> None: - """Add a tool to the server. - - The tool function can optionally request a Context object by adding a parameter - with the Context type annotation. See the @tool decorator for examples. - - Args: - fn: The function to register as a tool - name: Optional name for the tool (defaults to function name) - title: Optional human-readable title for the tool - description: Optional description of what the tool does - annotations: Optional ToolAnnotations providing additional tool information - structured_output: Controls whether the tool's output is structured or unstructured - - If None, auto-detects based on the function's return type annotation - - If True, creates a structured tool (return type annotation permitting) - - If False, unconditionally creates an unstructured tool - """ - self._tool_manager.add_tool( - fn, - name=name, - title=title, - description=description, - annotations=annotations, - icons=icons, - meta=meta, - structured_output=structured_output, - ) - - def remove_tool(self, name: str) -> None: - """Remove a tool from the server by name. - - Args: - name: The name of the tool to remove - - Raises: - ToolError: If the tool does not exist - """ - self._tool_manager.remove_tool(name) - - def tool( - self, - name: str | None = None, - title: str | None = None, - description: str | None = None, - annotations: ToolAnnotations | None = None, - icons: list[Icon] | None = None, - meta: dict[str, Any] | None = None, - structured_output: bool | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: - """Decorator to register a tool. - - Tools can optionally request a Context object by adding a parameter with the - Context type annotation. The context provides access to MCP capabilities like - logging, progress reporting, and resource access. - - Args: - name: Optional name for the tool (defaults to function name) - title: Optional human-readable title for the tool - description: Optional description of what the tool does - annotations: Optional ToolAnnotations providing additional tool information - structured_output: Controls whether the tool's output is structured or unstructured - - If None, auto-detects based on the function's return type annotation - - If True, creates a structured tool (return type annotation permitting) - - If False, unconditionally creates an unstructured tool - - Example: - @server.tool() - def my_tool(x: int) -> str: - return str(x) - - @server.tool() - def tool_with_context(x: int, ctx: Context) -> str: - ctx.info(f"Processing {x}") - return str(x) - - @server.tool() - async def async_tool(x: int, context: Context) -> str: - await context.report_progress(50, 100) - return str(x) - """ - # Check if user passed function directly instead of calling decorator - if callable(name): - raise TypeError( - "The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool" - ) - - def decorator(fn: AnyFunction) -> AnyFunction: - self.add_tool( - fn, - name=name, - title=title, - description=description, - annotations=annotations, - icons=icons, - meta=meta, - structured_output=structured_output, - ) - return fn - - return decorator - - def completion(self): - """Decorator to register a completion handler. - - The completion handler receives: - - ref: PromptReference or ResourceTemplateReference - - argument: CompletionArgument with name and partial value - - context: Optional CompletionContext with previously resolved arguments - - Example: - @mcp.completion() - async def handle_completion(ref, argument, context): - if isinstance(ref, ResourceTemplateReference): - # Return completions based on ref, argument, and context - return Completion(values=["option1", "option2"]) - return None - """ - return self._mcp_server.completion() - - def add_resource(self, resource: Resource) -> None: - """Add a resource to the server. - - Args: - resource: A Resource instance to add - """ - self._resource_manager.add_resource(resource) - - def resource( - self, - uri: str, - *, - name: str | None = None, - title: str | None = None, - description: str | None = None, - mime_type: str | None = None, - icons: list[Icon] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: - """Decorator to register a function as a resource. - - The function will be called when the resource is read to generate its content. - The function can return: - - str for text content - - bytes for binary content - - other types will be converted to JSON - - If the URI contains parameters (e.g. "resource://{param}") or the function - has parameters, it will be registered as a template resource. - - Args: - uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") - name: Optional name for the resource - title: Optional human-readable title for the resource - description: Optional description of the resource - mime_type: Optional MIME type for the resource - meta: Optional metadata dictionary for the resource - - Example: - @server.resource("resource://my-resource") - def get_data() -> str: - return "Hello, world!" - - @server.resource("resource://my-resource") - async get_data() -> str: - data = await fetch_data() - return f"Hello, world! {data}" - - @server.resource("resource://{city}/weather") - def get_weather(city: str) -> str: - return f"Weather for {city}" - - @server.resource("resource://{city}/weather") - async def get_weather(city: str) -> str: - data = await fetch_weather(city) - return f"Weather for {city}: {data}" - """ - # Check if user passed function directly instead of calling decorator - if callable(uri): - raise TypeError( - "The @resource decorator was used incorrectly. " - "Did you forget to call it? Use @resource('uri') instead of @resource" - ) - - def decorator(fn: AnyFunction) -> AnyFunction: - # Check if this should be a template - sig = inspect.signature(fn) - has_uri_params = "{" in uri and "}" in uri - has_func_params = bool(sig.parameters) - - if has_uri_params or has_func_params: - # Check for Context parameter to exclude from validation - context_param = find_context_parameter(fn) - - # Validate that URI params match function params (excluding context) - uri_params = set(re.findall(r"{(\w+)}", uri)) - # We need to remove the context_param from the resource function if - # there is any. - func_params = {p for p in sig.parameters.keys() if p != context_param} - - if uri_params != func_params: - raise ValueError( - f"Mismatch between URI parameters {uri_params} and function parameters {func_params}" - ) - - # Register as template - self._resource_manager.add_template( - fn=fn, - uri_template=uri, - name=name, - title=title, - description=description, - mime_type=mime_type, - icons=icons, - annotations=annotations, - meta=meta, - ) - else: - # Register as regular resource - resource = FunctionResource.from_function( - fn=fn, - uri=uri, - name=name, - title=title, - description=description, - mime_type=mime_type, - icons=icons, - annotations=annotations, - meta=meta, - ) - self.add_resource(resource) - return fn - - return decorator - - def add_prompt(self, prompt: Prompt) -> None: - """Add a prompt to the server. - - Args: - prompt: A Prompt instance to add - """ - self._prompt_manager.add_prompt(prompt) - - def prompt( - self, - name: str | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: - """Decorator to register a prompt. - - Args: - name: Optional name for the prompt (defaults to function name) - title: Optional human-readable title for the prompt - description: Optional description of what the prompt does - - Example: - @server.prompt() - def analyze_table(table_name: str) -> list[Message]: - schema = read_table_schema(table_name) - return [ - { - "role": "user", - "content": f"Analyze this schema:\n{schema}" - } - ] - - @server.prompt() - async def analyze_file(path: str) -> list[Message]: - content = await read_file(path) - return [ - { - "role": "user", - "content": { - "type": "resource", - "resource": { - "uri": f"file://{path}", - "text": content - } - } - } - ] - """ - # Check if user passed function directly instead of calling decorator - if callable(name): - raise TypeError( - "The @prompt decorator was used incorrectly. " - "Did you forget to call it? Use @prompt() instead of @prompt" - ) - - def decorator(func: AnyFunction) -> AnyFunction: - prompt = Prompt.from_function(func, name=name, title=title, description=description, icons=icons) - self.add_prompt(prompt) - return func - - return decorator - - def custom_route( - self, - path: str, - methods: list[str], - name: str | None = None, - include_in_schema: bool = True, - ): - """ - Decorator to register a custom HTTP route on the FastMCP server. - - Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, - which can be useful for OAuth callbacks, health checks, or admin APIs. - The handler function must be an async function that accepts a Starlette - Request and returns a Response. - - Routes using this decorator will not require authorization. It is intended - for uses that are either a part of authorization flows or intended to be - public such as health check endpoints. - - Args: - path: URL path for the route (e.g., "/oauth/callback") - methods: List of HTTP methods to support (e.g., ["GET", "POST"]) - name: Optional name for the route (to reference this route with - Starlette's reverse URL lookup feature) - include_in_schema: Whether to include in OpenAPI schema, defaults to True - - Example: - @server.custom_route("/health", methods=["GET"]) - async def health_check(request: Request) -> Response: - return JSONResponse({"status": "ok"}) - """ - - def decorator( # pragma: no cover - func: Callable[[Request], Awaitable[Response]], - ) -> Callable[[Request], Awaitable[Response]]: - self._custom_starlette_routes.append( - Route( - path, - endpoint=func, - methods=methods, - name=name, - include_in_schema=include_in_schema, - ) - ) - return func - - return decorator # pragma: no cover - - async def run_stdio_async(self) -> None: - """Run the server using stdio transport.""" - async with stdio_server() as (read_stream, write_stream): - await self._mcp_server.run( - read_stream, - write_stream, - self._mcp_server.create_initialization_options(), - ) - - async def run_sse_async(self, mount_path: str | None = None) -> None: # pragma: no cover - """Run the server using SSE transport.""" - import uvicorn - - starlette_app = self.sse_app(mount_path) - - config = uvicorn.Config( - starlette_app, - host=self.settings.host, - port=self.settings.port, - log_level=self.settings.log_level.lower(), - ) - server = uvicorn.Server(config) - await server.serve() - - async def run_streamable_http_async(self) -> None: # pragma: no cover - """Run the server using StreamableHTTP transport.""" - import uvicorn - - starlette_app = self.streamable_http_app() - - config = uvicorn.Config( - starlette_app, - host=self.settings.host, - port=self.settings.port, - log_level=self.settings.log_level.lower(), - ) - server = uvicorn.Server(config) - await server.serve() - - def _normalize_path(self, mount_path: str, endpoint: str) -> str: - """ - Combine mount path and endpoint to return a normalized path. - - Args: - mount_path: The mount path (e.g. "/github" or "/") - endpoint: The endpoint path (e.g. "/messages/") - - Returns: - Normalized path (e.g. "/github/messages/") - """ - # Special case: root path - if mount_path == "/": - return endpoint - - # Remove trailing slash from mount path - if mount_path.endswith("/"): - mount_path = mount_path[:-1] - - # Ensure endpoint starts with slash - if not endpoint.startswith("/"): - endpoint = "/" + endpoint - - # Combine paths - return mount_path + endpoint - - def sse_app(self, mount_path: str | None = None) -> Starlette: - """Return an instance of the SSE server app.""" - from starlette.middleware import Middleware - from starlette.routing import Mount, Route - - # Update mount_path in settings if provided - if mount_path is not None: - self.settings.mount_path = mount_path - - # Create normalized endpoint considering the mount path - normalized_message_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path) - - # Set up auth context and dependencies - - sse = SseServerTransport( - normalized_message_endpoint, - security_settings=self.settings.transport_security, - ) - - async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover - # Add client ID from auth context into request context if available - - async with sse.connect_sse( - scope, - receive, - send, - ) as streams: - await self._mcp_server.run( - streams[0], - streams[1], - self._mcp_server.create_initialization_options(), - ) - return Response() - - # Create routes - routes: list[Route | Mount] = [] - middleware: list[Middleware] = [] - required_scopes = [] - - # Set up auth if configured - if self.settings.auth: # pragma: no cover - required_scopes = self.settings.auth.required_scopes or [] - - # Add auth middleware if token verifier is available - if self._token_verifier: - middleware = [ - # extract auth info from request (but do not require it) - Middleware( - AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), - ), - # Add the auth context middleware to store - # authenticated user in a contextvar - Middleware(AuthContextMiddleware), - ] - - # Add auth endpoints if auth server provider is configured - if self._auth_server_provider: - from mcp.server.auth.routes import create_auth_routes - - routes.extend( - create_auth_routes( - provider=self._auth_server_provider, - issuer_url=self.settings.auth.issuer_url, - service_documentation_url=self.settings.auth.service_documentation_url, - client_registration_options=self.settings.auth.client_registration_options, - revocation_options=self.settings.auth.revocation_options, - ) - ) - - # When auth is configured, require authentication - if self._token_verifier: # pragma: no cover - # Determine resource metadata URL - resource_metadata_url = None - if self.settings.auth and self.settings.auth.resource_server_url: - from mcp.server.auth.routes import build_resource_metadata_url - - # Build compliant metadata URL for WWW-Authenticate header - resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url) - - # Auth is enabled, wrap the endpoints with RequireAuthMiddleware - routes.append( - Route( - self.settings.sse_path, - endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), - methods=["GET"], - ) - ) - routes.append( - Mount( - self.settings.message_path, - app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url), - ) - ) - else: # pragma: no cover - # Auth is disabled, no need for RequireAuthMiddleware - # Since handle_sse is an ASGI app, we need to create a compatible endpoint - async def sse_endpoint(request: Request) -> Response: - # Convert the Starlette request to ASGI parameters - return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] - - routes.append( - Route( - self.settings.sse_path, - endpoint=sse_endpoint, - methods=["GET"], - ) - ) - routes.append( - Mount( - self.settings.message_path, - app=sse.handle_post_message, - ) - ) - # Add protected resource metadata endpoint if configured as RS - if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover - from mcp.server.auth.routes import create_protected_resource_routes - - routes.extend( - create_protected_resource_routes( - resource_url=self.settings.auth.resource_server_url, - authorization_servers=[self.settings.auth.issuer_url], - scopes_supported=self.settings.auth.required_scopes, - ) - ) - - # mount these routes last, so they have the lowest route matching precedence - routes.extend(self._custom_starlette_routes) - - # Create Starlette app with routes and middleware - return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware) - - def streamable_http_app(self) -> Starlette: - """Return an instance of the StreamableHTTP server app.""" - from starlette.middleware import Middleware - - # Create session manager on first call (lazy initialization) - if self._session_manager is None: # pragma: no branch - self._session_manager = StreamableHTTPSessionManager( - app=self._mcp_server, - event_store=self._event_store, - retry_interval=self._retry_interval, - json_response=self.settings.json_response, - stateless=self.settings.stateless_http, # Use the stateless setting - security_settings=self.settings.transport_security, - ) - - # Create the ASGI handler - streamable_http_app = StreamableHTTPASGIApp(self._session_manager) - - # Create routes - routes: list[Route | Mount] = [] - middleware: list[Middleware] = [] - required_scopes = [] - - # Set up auth if configured - if self.settings.auth: # pragma: no cover - required_scopes = self.settings.auth.required_scopes or [] - - # Add auth middleware if token verifier is available - if self._token_verifier: - middleware = [ - Middleware( - AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), - ), - Middleware(AuthContextMiddleware), - ] - - # Add auth endpoints if auth server provider is configured - if self._auth_server_provider: - from mcp.server.auth.routes import create_auth_routes - - routes.extend( - create_auth_routes( - provider=self._auth_server_provider, - issuer_url=self.settings.auth.issuer_url, - service_documentation_url=self.settings.auth.service_documentation_url, - client_registration_options=self.settings.auth.client_registration_options, - revocation_options=self.settings.auth.revocation_options, - ) - ) - - # Set up routes with or without auth - if self._token_verifier: # pragma: no cover - # Determine resource metadata URL - resource_metadata_url = None - if self.settings.auth and self.settings.auth.resource_server_url: - from mcp.server.auth.routes import build_resource_metadata_url - - # Build compliant metadata URL for WWW-Authenticate header - resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url) - - routes.append( - Route( - self.settings.streamable_http_path, - endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url), - ) - ) - else: - # Auth is disabled, no wrapper needed - routes.append( - Route( - self.settings.streamable_http_path, - endpoint=streamable_http_app, - ) - ) - - # Add protected resource metadata endpoint if configured as RS - if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover - from mcp.server.auth.routes import create_protected_resource_routes - - routes.extend( - create_protected_resource_routes( - resource_url=self.settings.auth.resource_server_url, - authorization_servers=[self.settings.auth.issuer_url], - scopes_supported=self.settings.auth.required_scopes, - ) - ) - - routes.extend(self._custom_starlette_routes) - - return Starlette( - debug=self.settings.debug, - routes=routes, - middleware=middleware, - lifespan=lambda app: self.session_manager.run(), - ) - - async def list_prompts(self) -> list[MCPPrompt]: - """List all available prompts.""" - prompts = self._prompt_manager.list_prompts() - return [ - MCPPrompt( - name=prompt.name, - title=prompt.title, - description=prompt.description, - arguments=[ - MCPPromptArgument( - name=arg.name, - description=arg.description, - required=arg.required, - ) - for arg in (prompt.arguments or []) - ], - icons=prompt.icons, - ) - for prompt in prompts - ] - - async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult: - """Get a prompt by name with arguments.""" - try: - prompt = self._prompt_manager.get_prompt(name) - if not prompt: - raise ValueError(f"Unknown prompt: {name}") - - messages = await prompt.render(arguments, context=self.get_context()) - - return GetPromptResult( - description=prompt.description, - messages=pydantic_core.to_jsonable_python(messages), - ) - except Exception as e: - logger.exception(f"Error getting prompt {name}") - raise ValueError(str(e)) - - -class StreamableHTTPASGIApp: - """ - ASGI application for Streamable HTTP server transport. - """ - - def __init__(self, session_manager: StreamableHTTPSessionManager): - self.session_manager = session_manager - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover - await self.session_manager.handle_request(scope, receive, send) - - -class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): - """Context object providing access to MCP capabilities. - - This provides a cleaner interface to MCP's RequestContext functionality. - It gets injected into tool and resource functions that request it via type hints. - - To use context in a tool function, add a parameter with the Context type annotation: - - ```python - @server.tool() - def my_tool(x: int, ctx: Context) -> str: - # Log messages to the client - ctx.info(f"Processing {x}") - ctx.debug("Debug info") - ctx.warning("Warning message") - ctx.error("Error message") - - # Report progress - ctx.report_progress(50, 100) - - # Access resources - data = ctx.read_resource("resource://data") - - # Get request info - request_id = ctx.request_id - client_id = ctx.client_id - - return str(x) - ``` - - The context parameter name can be anything as long as it's annotated with Context. - The context is optional - tools that don't need it can omit the parameter. - """ - - _request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None - _fastmcp: FastMCP | None - - def __init__( - self, - *, - request_context: (RequestContext[ServerSessionT, LifespanContextT, RequestT] | None) = None, - fastmcp: FastMCP | None = None, - **kwargs: Any, - ): - super().__init__(**kwargs) - self._request_context = request_context - self._fastmcp = fastmcp - - @property - def fastmcp(self) -> FastMCP: - """Access to the FastMCP server.""" - if self._fastmcp is None: # pragma: no cover - raise ValueError("Context is not available outside of a request") - return self._fastmcp # pragma: no cover - - @property - def request_context( - self, - ) -> RequestContext[ServerSessionT, LifespanContextT, RequestT]: - """Access to the underlying request context.""" - if self._request_context is None: # pragma: no cover - raise ValueError("Context is not available outside of a request") - return self._request_context - - async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: - """Report progress for the current operation. - - Args: - progress: Current progress value e.g. 24 - total: Optional total value e.g. 100 - message: Optional message e.g. Starting render... - """ - progress_token = self.request_context.meta.progressToken if self.request_context.meta else None - - if progress_token is None: # pragma: no cover - return - - await self.request_context.session.send_progress_notification( - progress_token=progress_token, - progress=progress, - total=total, - message=message, - ) - - async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: - """Read a resource by URI. - - Args: - uri: Resource URI to read - - Returns: - The resource content as either text or bytes - """ - assert self._fastmcp is not None, "Context is not available outside of a request" - return await self._fastmcp.read_resource(uri) - - async def elicit( - self, - message: str, - schema: type[ElicitSchemaModelT], - ) -> ElicitationResult[ElicitSchemaModelT]: - """Elicit information from the client/user. - - This method can be used to interactively ask for additional information from the - client within a tool's execution. The client might display the message to the - user and collect a response according to the provided schema. Or in case a - client is an agent, it might decide how to handle the elicitation -- either by asking - the user or automatically generating a response. - - Args: - schema: A Pydantic model class defining the expected response structure, according to the specification, - only primive types are allowed. - message: Optional message to present to the user. If not provided, will use - a default message based on the schema - - Returns: - An ElicitationResult containing the action taken and the data if accepted - - Note: - Check the result.action to determine if the user accepted, declined, or cancelled. - The result.data will only be populated if action is "accept" and validation succeeded. - """ - - return await elicit_with_validation( - session=self.request_context.session, - message=message, - schema=schema, - related_request_id=self.request_id, - ) - - async def elicit_url( - self, - message: str, - url: str, - elicitation_id: str, - ) -> UrlElicitationResult: - """Request URL mode elicitation from the client. - - This directs the user to an external URL for out-of-band interactions - that must not pass through the MCP client. Use this for: - - Collecting sensitive credentials (API keys, passwords) - - OAuth authorization flows with third-party services - - Payment and subscription flows - - Any interaction where data should not pass through the LLM context - - The response indicates whether the user consented to navigate to the URL. - The actual interaction happens out-of-band. When the elicitation completes, - call `self.session.send_elicit_complete(elicitation_id)` to notify the client. - - Args: - message: Human-readable explanation of why the interaction is needed - url: The URL the user should navigate to - elicitation_id: Unique identifier for tracking this elicitation - - Returns: - UrlElicitationResult indicating accept, decline, or cancel - """ - return await _elicit_url( - session=self.request_context.session, - message=message, - url=url, - elicitation_id=elicitation_id, - related_request_id=self.request_id, - ) - - async def log( - self, - level: Literal["debug", "info", "warning", "error"], - message: str, - *, - logger_name: str | None = None, - ) -> None: - """Send a log message to the client. - - Args: - level: Log level (debug, info, warning, error) - message: Log message - logger_name: Optional logger name - **extra: Additional structured data to include - """ - await self.request_context.session.send_log_message( - level=level, - data=message, - logger=logger_name, - related_request_id=self.request_id, - ) - - # TODO(maxisbey): see if this is needed otherwise remove - @property - def client_id(self) -> str | None: - """Get the client ID if available. - - Note: this reads from the MCP request's `_meta` params, not the OAuth - bearer token. For that, use `get_access_token().client_id`. - """ - return ( - getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None - ) # pragma: no cover - - @property - def request_id(self) -> str: - """Get the unique ID for this request.""" - return str(self.request_context.request_id) - - @property - def session(self): - """Access to the underlying session for advanced usage.""" - return self.request_context.session - - async def close_sse_stream(self) -> None: - """Close the SSE stream to trigger client reconnection. - - This method closes the HTTP connection for the current request, triggering - client reconnection. Events continue to be stored in the event store and will - be replayed when the client reconnects with Last-Event-ID. - - Use this to implement polling behavior during long-running operations - - client will reconnect after the retry interval specified in the priming event. - - Note: - This is a no-op if not using StreamableHTTP transport with event_store. - The callback is only available when event_store is configured. - """ - if self._request_context and self._request_context.close_sse_stream: # pragma: no cover - await self._request_context.close_sse_stream() - - async def close_standalone_sse_stream(self) -> None: - """Close the standalone GET SSE stream to trigger client reconnection. - - This method closes the HTTP connection for the standalone GET stream used - for unsolicited server-to-client notifications. The client SHOULD reconnect - with Last-Event-ID to resume receiving notifications. - - Note: - This is a no-op if not using StreamableHTTP transport with event_store. - Currently, client reconnection for standalone GET streams is NOT - implemented - this is a known gap. - """ - if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover - await self._request_context.close_standalone_sse_stream() - - # Convenience methods for common log levels - async def debug(self, message: str, **extra: Any) -> None: - """Send a debug log message.""" - await self.log("debug", message, **extra) - - async def info(self, message: str, **extra: Any) -> None: - """Send an info log message.""" - await self.log("info", message, **extra) - - async def warning(self, message: str, **extra: Any) -> None: - """Send a warning log message.""" - await self.log("warning", message, **extra) - - async def error(self, message: str, **extra: Any) -> None: - """Send an error log message.""" - await self.log("error", message, **extra) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/__init__.py deleted file mode 100644 index ae9c656..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .base import Tool -from .tool_manager import ToolManager - -__all__ = ["Tool", "ToolManager"] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/base.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/base.py deleted file mode 100644 index 1ae6d90..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/base.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations as _annotations - -import functools -import inspect -from collections.abc import Callable -from functools import cached_property -from typing import TYPE_CHECKING, Any - -from pydantic import BaseModel, Field - -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter -from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.shared.tool_name_validation import validate_and_warn_tool_name -from mcp.types import Icon, ToolAnnotations - -if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT - - -class Tool(BaseModel): - """Internal tool registration info.""" - - fn: Callable[..., Any] = Field(exclude=True) - name: str = Field(description="Name of the tool") - title: str | None = Field(None, description="Human-readable title of the tool") - description: str = Field(description="Description of what the tool does") - parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") - fn_metadata: FuncMetadata = Field( - description="Metadata about the function including a pydantic model for tool arguments" - ) - is_async: bool = Field(description="Whether the tool is async") - context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") - annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") - icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") - meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") - - @cached_property - def output_schema(self) -> dict[str, Any] | None: - return self.fn_metadata.output_schema - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - name: str | None = None, - title: str | None = None, - description: str | None = None, - context_kwarg: str | None = None, - annotations: ToolAnnotations | None = None, - icons: list[Icon] | None = None, - meta: dict[str, Any] | None = None, - structured_output: bool | None = None, - ) -> Tool: - """Create a Tool from a function.""" - func_name = name or fn.__name__ - - validate_and_warn_tool_name(func_name) - - if func_name == "": - raise ValueError("You must provide a name for lambda functions") - - func_doc = description or fn.__doc__ or "" - is_async = _is_async_callable(fn) - - if context_kwarg is None: # pragma: no branch - context_kwarg = find_context_parameter(fn) - - func_arg_metadata = func_metadata( - fn, - skip_names=[context_kwarg] if context_kwarg is not None else [], - structured_output=structured_output, - ) - parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) - - return cls( - fn=fn, - name=func_name, - title=title, - description=func_doc, - parameters=parameters, - fn_metadata=func_arg_metadata, - is_async=is_async, - context_kwarg=context_kwarg, - annotations=annotations, - icons=icons, - meta=meta, - ) - - async def run( - self, - arguments: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - convert_result: bool = False, - ) -> Any: - """Run the tool with arguments.""" - try: - result = await self.fn_metadata.call_fn_with_arg_validation( - self.fn, - self.is_async, - arguments, - {self.context_kwarg: context} if self.context_kwarg is not None else None, - ) - - if convert_result: - result = self.fn_metadata.convert_result(result) - - return result - except UrlElicitationRequiredError: - # Re-raise UrlElicitationRequiredError so it can be properly handled - # as an MCP error response with code -32042 - raise - except Exception as e: - raise ToolError(f"Error executing tool {self.name}: {e}") from e - - -def _is_async_callable(obj: Any) -> bool: - while isinstance(obj, functools.partial): # pragma: no cover - obj = obj.func - - return inspect.iscoroutinefunction(obj) or ( - callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/tool_manager.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/tool_manager.py deleted file mode 100644 index 095753d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/tools/tool_manager.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations as _annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.tools.base import Tool -from mcp.server.fastmcp.utilities.logging import get_logger -from mcp.shared.context import LifespanContextT, RequestT -from mcp.types import Icon, ToolAnnotations - -if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - -logger = get_logger(__name__) - - -class ToolManager: - """Manages FastMCP tools.""" - - def __init__( - self, - warn_on_duplicate_tools: bool = True, - *, - tools: list[Tool] | None = None, - ): - self._tools: dict[str, Tool] = {} - if tools is not None: - for tool in tools: - if warn_on_duplicate_tools and tool.name in self._tools: - logger.warning(f"Tool already exists: {tool.name}") - self._tools[tool.name] = tool - - self.warn_on_duplicate_tools = warn_on_duplicate_tools - - def get_tool(self, name: str) -> Tool | None: - """Get tool by name.""" - return self._tools.get(name) - - def list_tools(self) -> list[Tool]: - """List all registered tools.""" - return list(self._tools.values()) - - def add_tool( - self, - fn: Callable[..., Any], - name: str | None = None, - title: str | None = None, - description: str | None = None, - annotations: ToolAnnotations | None = None, - icons: list[Icon] | None = None, - meta: dict[str, Any] | None = None, - structured_output: bool | None = None, - ) -> Tool: - """Add a tool to the server.""" - tool = Tool.from_function( - fn, - name=name, - title=title, - description=description, - annotations=annotations, - icons=icons, - meta=meta, - structured_output=structured_output, - ) - existing = self._tools.get(tool.name) - if existing: - if self.warn_on_duplicate_tools: - logger.warning(f"Tool already exists: {tool.name}") - return existing - self._tools[tool.name] = tool - return tool - - def remove_tool(self, name: str) -> None: - """Remove a tool by name.""" - if name not in self._tools: - raise ToolError(f"Unknown tool: {name}") - del self._tools[name] - - async def call_tool( - self, - name: str, - arguments: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - convert_result: bool = False, - ) -> Any: - """Call a tool by name with arguments.""" - tool = self.get_tool(name) - if not tool: - raise ToolError(f"Unknown tool: {name}") - - return await tool.run(arguments, context=context, convert_result=convert_result) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/__init__.py deleted file mode 100644 index be448f9..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""FastMCP utility modules.""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/context_injection.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/context_injection.py deleted file mode 100644 index 45e0aea..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/context_injection.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Context injection utilities for FastMCP.""" - -from __future__ import annotations - -import inspect -import typing -from collections.abc import Callable -from typing import Any - - -def find_context_parameter(fn: Callable[..., Any]) -> str | None: - """Find the parameter that should receive the Context object. - - Searches through the function's signature to find a parameter - with a Context type annotation. - - Args: - fn: The function to inspect - - Returns: - The name of the context parameter, or None if not found - """ - from mcp.server.fastmcp.server import Context - - # Get type hints to properly resolve string annotations - try: - hints = typing.get_type_hints(fn) - except Exception: # pragma: no cover - # If we can't resolve type hints, we can't find the context parameter - return None - - # Check each parameter's type hint - for param_name, annotation in hints.items(): - # Handle direct Context type - if inspect.isclass(annotation) and issubclass(annotation, Context): - return param_name - - # Handle generic types like Optional[Context] - origin = typing.get_origin(annotation) - if origin is not None: - args = typing.get_args(annotation) - for arg in args: - if inspect.isclass(arg) and issubclass(arg, Context): - return param_name - - return None - - -def inject_context( - fn: Callable[..., Any], - kwargs: dict[str, Any], - context: Any | None, - context_kwarg: str | None, -) -> dict[str, Any]: - """Inject context into function kwargs if needed. - - Args: - fn: The function that will be called - kwargs: The current keyword arguments - context: The context object to inject (if any) - context_kwarg: The name of the parameter to inject into - - Returns: - Updated kwargs with context injected if applicable - """ - if context_kwarg is not None and context is not None: - return {**kwargs, context_kwarg: context} - return kwargs diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/func_metadata.py deleted file mode 100644 index 241100d..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/func_metadata.py +++ /dev/null @@ -1,541 +0,0 @@ -import inspect -import json -from collections.abc import Awaitable, Callable, Sequence -from itertools import chain -from types import GenericAlias -from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints - -import pydantic_core -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PydanticUserError, - RootModel, - WithJsonSchema, - create_model, -) -from pydantic.fields import FieldInfo -from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind -from typing_extensions import is_typeddict -from typing_inspection.introspection import ( - UNKNOWN, - AnnotationSource, - ForbiddenQualifier, - inspect_annotation, - is_union_origin, -) - -from mcp.server.fastmcp.exceptions import InvalidSignature -from mcp.server.fastmcp.utilities.logging import get_logger -from mcp.server.fastmcp.utilities.types import Audio, Image -from mcp.types import CallToolResult, ContentBlock, TextContent - -logger = get_logger(__name__) - - -class StrictJsonSchema(GenerateJsonSchema): - """A JSON schema generator that raises exceptions instead of emitting warnings. - - This is used to detect non-serializable types during schema generation. - """ - - def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None: - # Raise an exception instead of emitting a warning - raise ValueError(f"JSON schema warning: {kind} - {detail}") - - -class ArgModelBase(BaseModel): - """A model representing the arguments to a function.""" - - def model_dump_one_level(self) -> dict[str, Any]: - """Return a dict of the model's fields, one level deep. - - That is, sub-models etc are not dumped - they are kept as pydantic models. - """ - kwargs: dict[str, Any] = {} - for field_name, field_info in self.__class__.model_fields.items(): - value = getattr(self, field_name) - # Use the alias if it exists, otherwise use the field name - output_name = field_info.alias if field_info.alias else field_name - kwargs[output_name] = value - return kwargs - - model_config = ConfigDict( - arbitrary_types_allowed=True, - ) - - -class FuncMetadata(BaseModel): - arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] - output_schema: dict[str, Any] | None = None - output_model: Annotated[type[BaseModel], WithJsonSchema(None)] | None = None - wrap_output: bool = False - - async def call_fn_with_arg_validation( - self, - fn: Callable[..., Any | Awaitable[Any]], - fn_is_async: bool, - arguments_to_validate: dict[str, Any], - arguments_to_pass_directly: dict[str, Any] | None, - ) -> Any: - """Call the given function with arguments validated and injected. - - Arguments are first attempted to be parsed from JSON, then validated against - the argument model, before being passed to the function. - """ - arguments_pre_parsed = self.pre_parse_json(arguments_to_validate) - arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed) - arguments_parsed_dict = arguments_parsed_model.model_dump_one_level() - - arguments_parsed_dict |= arguments_to_pass_directly or {} - - if fn_is_async: - return await fn(**arguments_parsed_dict) - else: - return fn(**arguments_parsed_dict) - - def convert_result(self, result: Any) -> Any: - """ - Convert the result of a function call to the appropriate format for - the lowlevel server tool call handler: - - - If output_model is None, return the unstructured content directly. - - If output_model is not None, convert the result to structured output format - (dict[str, Any]) and return both unstructured and structured content. - - Note: we return unstructured content here **even though the lowlevel server - tool call handler provides generic backwards compatibility serialization of - structured content**. This is for FastMCP backwards compatibility: we need to - retain FastMCP's ad hoc conversion logic for constructing unstructured output - from function return values, whereas the lowlevel server simply serializes - the structured output. - """ - if isinstance(result, CallToolResult): - if self.output_schema is not None: - assert self.output_model is not None, "Output model must be set if output schema is defined" - self.output_model.model_validate(result.structuredContent) - return result - - unstructured_content = _convert_to_content(result) - - if self.output_schema is None: - return unstructured_content - else: - if self.wrap_output: - result = {"result": result} - - assert self.output_model is not None, "Output model must be set if output schema is defined" - validated = self.output_model.model_validate(result) - structured_content = validated.model_dump(mode="json", by_alias=True) - - return (unstructured_content, structured_content) - - def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: - """Pre-parse data from JSON. - - Return a dict with same keys as input but with values parsed from JSON - if appropriate. - - This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside - a string rather than an actual list. Claude desktop is prone to this - in fact - it seems incapable of NOT doing this. For sub-models, it tends to pass - dicts (JSON objects) as JSON strings, which can be pre-parsed here. - """ - new_data = data.copy() # Shallow copy - - # Build a mapping from input keys (including aliases) to field info - key_to_field_info: dict[str, FieldInfo] = {} - for field_name, field_info in self.arg_model.model_fields.items(): - # Map both the field name and its alias (if any) to the field info - key_to_field_info[field_name] = field_info - if field_info.alias: - key_to_field_info[field_info.alias] = field_info - - for data_key, data_value in data.items(): - if data_key not in key_to_field_info: # pragma: no cover - continue - - field_info = key_to_field_info[data_key] - if isinstance(data_value, str) and field_info.annotation is not str: - try: - pre_parsed = json.loads(data_value) - except json.JSONDecodeError: - continue # Not JSON - skip - if isinstance(pre_parsed, str | int | float): - # This is likely that the raw value is e.g. `"hello"` which we - # Should really be parsed as '"hello"' in Python - but if we parse - # it as JSON it'll turn into just 'hello'. So we skip it. - continue - new_data[data_key] = pre_parsed - assert new_data.keys() == data.keys() - return new_data - - model_config = ConfigDict( - arbitrary_types_allowed=True, - ) - - -def func_metadata( - func: Callable[..., Any], - skip_names: Sequence[str] = (), - structured_output: bool | None = None, -) -> FuncMetadata: - """Given a function, return metadata including a pydantic model representing its - signature. - - The use case for this is - ``` - meta = func_metadata(func) - validated_args = meta.arg_model.model_validate(some_raw_data_dict) - return func(**validated_args.model_dump_one_level()) - ``` - - **critically** it also provides pre-parse helper to attempt to parse things from - JSON. - - Args: - func: The function to convert to a pydantic model - skip_names: A list of parameter names to skip. These will not be included in - the model. - structured_output: Controls whether the tool's output is structured or unstructured - - If None, auto-detects based on the function's return type annotation - - If True, creates a structured tool (return type annotation permitting) - - If False, unconditionally creates an unstructured tool - - If structured, creates a Pydantic model for the function's result based on its annotation. - Supports various return types: - - BaseModel subclasses (used directly) - - Primitive types (str, int, float, bool, bytes, None) - wrapped in a - model with a 'result' field - - TypedDict - converted to a Pydantic model with same fields - - Dataclasses and other annotated classes - converted to Pydantic models - - Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field - - Returns: - A FuncMetadata object containing: - - arg_model: A pydantic model representing the function's arguments - - output_model: A pydantic model for the return type if output is structured - - output_conversion: Records how function output should be converted before returning. - """ - try: - sig = inspect.signature(func, eval_str=True) - except NameError as e: # pragma: no cover - # This raise could perhaps be skipped, and we (FastMCP) just call - # model_rebuild right before using it 🤷 - raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e - params = sig.parameters - dynamic_pydantic_model_params: dict[str, Any] = {} - for param in params.values(): - if param.name.startswith("_"): # pragma: no cover - raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'") - if param.name in skip_names: - continue - - annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any - field_name = param.name - field_kwargs: dict[str, Any] = {} - field_metadata: list[Any] = [] - - if param.annotation is inspect.Parameter.empty: - field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"})) - # Check if the parameter name conflicts with BaseModel attributes - # This is necessary because Pydantic warns about shadowing parent attributes - if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)): - # Use an alias to avoid the shadowing warning - field_kwargs["alias"] = field_name - # Use a prefixed field name - field_name = f"field_{field_name}" - - if param.default is not inspect.Parameter.empty: - dynamic_pydantic_model_params[field_name] = ( - Annotated[(annotation, *field_metadata, Field(**field_kwargs))], - param.default, - ) - else: - dynamic_pydantic_model_params[field_name] = Annotated[(annotation, *field_metadata, Field(**field_kwargs))] - - arguments_model = create_model( - f"{func.__name__}Arguments", - __base__=ArgModelBase, - **dynamic_pydantic_model_params, - ) - - if structured_output is False: - return FuncMetadata(arg_model=arguments_model) - - # set up structured output support based on return type annotation - - if sig.return_annotation is inspect.Parameter.empty and structured_output is True: - raise InvalidSignature(f"Function {func.__name__}: return annotation required for structured output") - - try: - inspected_return_ann = inspect_annotation(sig.return_annotation, annotation_source=AnnotationSource.FUNCTION) - except ForbiddenQualifier as e: - raise InvalidSignature(f"Function {func.__name__}: return annotation contains an invalid type qualifier") from e - - return_type_expr = inspected_return_ann.type - - # `AnnotationSource.FUNCTION` allows no type qualifier to be used, so `return_type_expr` is guaranteed to *not* be - # unknown (i.e. a bare `Final`). - assert return_type_expr is not UNKNOWN - - if is_union_origin(get_origin(return_type_expr)): - args = get_args(return_type_expr) - # Check if CallToolResult appears in the union (excluding None for Optional check) - if any(isinstance(arg, type) and issubclass(arg, CallToolResult) for arg in args if arg is not type(None)): - raise InvalidSignature( - f"Function {func.__name__}: CallToolResult cannot be used in Union or Optional types. " - "To return empty results, use: CallToolResult(content=[])" - ) - - original_annotation: Any - # if the typehint is CallToolResult, the user either intends to return without validation - # or they provided validation as Annotated metadata - if isinstance(return_type_expr, type) and issubclass(return_type_expr, CallToolResult): - if inspected_return_ann.metadata: - return_type_expr = inspected_return_ann.metadata[0] - if len(inspected_return_ann.metadata) >= 2: - # Reconstruct the original annotation, by preserving the remaining metadata, - # i.e. from `Annotated[CallToolResult, ReturnType, Gt(1)]` to - # `Annotated[ReturnType, Gt(1)]`: - original_annotation = Annotated[ - (return_type_expr, *inspected_return_ann.metadata[1:]) - ] # pragma: no cover - else: - # We only had `Annotated[CallToolResult, ReturnType]`, treat the original annotation - # as beging `ReturnType`: - original_annotation = return_type_expr - else: - return FuncMetadata(arg_model=arguments_model) - else: - original_annotation = sig.return_annotation - - output_model, output_schema, wrap_output = _try_create_model_and_schema( - original_annotation, return_type_expr, func.__name__ - ) - - if output_model is None and structured_output is True: - # Model creation failed or produced warnings - no structured output - raise InvalidSignature( - f"Function {func.__name__}: return type {return_type_expr} is not serializable for structured output" - ) - - return FuncMetadata( - arg_model=arguments_model, - output_schema=output_schema, - output_model=output_model, - wrap_output=wrap_output, - ) - - -def _try_create_model_and_schema( - original_annotation: Any, - type_expr: Any, - func_name: str, -) -> tuple[type[BaseModel] | None, dict[str, Any] | None, bool]: - """Try to create a model and schema for the given annotation without warnings. - - Args: - original_annotation: The original return annotation (may be wrapped in `Annotated`). - type_expr: The underlying type expression derived from the return annotation - (`Annotated` and type qualifiers were stripped). - func_name: The name of the function. - - Returns: - tuple of (model or None, schema or None, wrap_output) - Model and schema are None if warnings occur or creation fails. - wrap_output is True if the result needs to be wrapped in {"result": ...} - """ - model = None - wrap_output = False - - # First handle special case: None - if type_expr is None: - model = _create_wrapped_model(func_name, original_annotation) - wrap_output = True - - # Handle GenericAlias types (list[str], dict[str, int], Union[str, int], etc.) - elif isinstance(type_expr, GenericAlias): - origin = get_origin(type_expr) - - # Special case: dict with string keys can use RootModel - if origin is dict: - args = get_args(type_expr) - if len(args) == 2 and args[0] is str: - # TODO: should we use the original annotation? We are loosing any potential `Annotated` - # metadata for Pydantic here: - model = _create_dict_model(func_name, type_expr) - else: - # dict with non-str keys needs wrapping - model = _create_wrapped_model(func_name, original_annotation) - wrap_output = True - else: - # All other generic types need wrapping (list, tuple, Union, Optional, etc.) - model = _create_wrapped_model(func_name, original_annotation) - wrap_output = True - - # Handle regular type objects - elif isinstance(type_expr, type): - type_annotation = cast(type[Any], type_expr) - - # Case 1: BaseModel subclasses (can be used directly) - if issubclass(type_annotation, BaseModel): - model = type_annotation - - # Case 2: TypedDicts: - elif is_typeddict(type_annotation): - model = _create_model_from_typeddict(type_annotation) - - # Case 3: Primitive types that need wrapping - elif type_annotation in (str, int, float, bool, bytes, type(None)): - model = _create_wrapped_model(func_name, original_annotation) - wrap_output = True - - # Case 4: Other class types (dataclasses, regular classes with annotations) - else: - type_hints = get_type_hints(type_annotation) - if type_hints: - # Classes with type hints can be converted to Pydantic models - model = _create_model_from_class(type_annotation, type_hints) - # Classes without type hints are not serializable - model remains None - - # Handle any other types not covered above - else: - # This includes typing constructs that aren't GenericAlias in Python 3.10 - # (e.g., Union, Optional in some Python versions) - model = _create_wrapped_model(func_name, original_annotation) - wrap_output = True - - if model: - # If we successfully created a model, try to get its schema - # Use StrictJsonSchema to raise exceptions instead of warnings - try: - schema = model.model_json_schema(schema_generator=StrictJsonSchema) - except ( - PydanticUserError, - TypeError, - ValueError, - pydantic_core.SchemaError, - pydantic_core.ValidationError, - ) as e: - # These are expected errors when a type can't be converted to a Pydantic schema - # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema); - # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13 - # ValueError: When there are issues with the type definition (including our custom warnings) - # SchemaError: When Pydantic can't build a schema - # ValidationError: When validation fails - logger.info(f"Cannot create schema for type {type_expr} in {func_name}: {type(e).__name__}: {e}") - return None, None, False - - return model, schema, wrap_output - - return None, None, False - - -_no_default = object() - - -def _create_model_from_class(cls: type[Any], type_hints: dict[str, Any]) -> type[BaseModel]: - """Create a Pydantic model from an ordinary class. - - The created model will: - - Have the same name as the class - - Have fields with the same names and types as the class's fields - - Include all fields whose type does not include None in the set of required fields - - Precondition: cls must have type hints (i.e., `type_hints` is non-empty) - """ - model_fields: dict[str, Any] = {} - for field_name, field_type in type_hints.items(): - if field_name.startswith("_"): # pragma: no cover - continue - - default = getattr(cls, field_name, _no_default) - if default is _no_default: - model_fields[field_name] = field_type - else: - model_fields[field_name] = (field_type, default) - - return create_model(cls.__name__, __config__=ConfigDict(from_attributes=True), **model_fields) - - -def _create_model_from_typeddict(td_type: type[Any]) -> type[BaseModel]: - """Create a Pydantic model from a TypedDict. - - The created model will have the same name and fields as the TypedDict. - """ - type_hints = get_type_hints(td_type) - required_keys = getattr(td_type, "__required_keys__", set(type_hints.keys())) - - model_fields: dict[str, Any] = {} - for field_name, field_type in type_hints.items(): - if field_name not in required_keys: - # For optional TypedDict fields, set default=None - # This makes them not required in the Pydantic model - # The model should use exclude_unset=True when dumping to get TypedDict semantics - model_fields[field_name] = (field_type, None) - else: - model_fields[field_name] = field_type - - return create_model(td_type.__name__, **model_fields) - - -def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]: - """Create a model that wraps a type in a 'result' field. - - This is used for primitive types, generic types like list/dict, etc. - """ - model_name = f"{func_name}Output" - - return create_model(model_name, result=annotation) - - -def _create_dict_model(func_name: str, dict_annotation: Any) -> type[BaseModel]: - """Create a RootModel for dict[str, T] types.""" - - class DictModel(RootModel[dict_annotation]): - pass - - # Give it a meaningful name - DictModel.__name__ = f"{func_name}DictOutput" - DictModel.__qualname__ = f"{func_name}DictOutput" - - return DictModel - - -def _convert_to_content( - result: Any, -) -> Sequence[ContentBlock]: - """ - Convert a result to a sequence of content objects. - - Note: This conversion logic comes from previous versions of FastMCP and is being - retained for purposes of backwards compatibility. It produces different unstructured - output than the lowlevel server tool call handler, which just serializes structured - content verbatim. - """ - if result is None: # pragma: no cover - return [] - - if isinstance(result, ContentBlock): - return [result] - - if isinstance(result, Image): - return [result.to_image_content()] - - if isinstance(result, Audio): - return [result.to_audio_content()] - - if isinstance(result, list | tuple): - return list( - chain.from_iterable( - _convert_to_content(item) - for item in result # type: ignore - ) - ) - - if not isinstance(result, str): - result = pydantic_core.to_json(result, fallback=str, indent=2).decode() - - return [TextContent(type="text", text=result)] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/logging.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/logging.py deleted file mode 100644 index 4b47d3b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/logging.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Logging utilities for FastMCP.""" - -import logging -from typing import Literal - - -def get_logger(name: str) -> logging.Logger: - """Get a logger nested under MCPnamespace. - - Args: - name: the name of the logger, which will be prefixed with 'FastMCP.' - - Returns: - a configured logger instance - """ - return logging.getLogger(name) - - -def configure_logging( - level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", -) -> None: - """Configure logging for MCP. - - Args: - level: the log level to use - """ - handlers: list[logging.Handler] = [] - try: # pragma: no cover - from rich.console import Console - from rich.logging import RichHandler - - handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True)) - except ImportError: # pragma: no cover - pass - - if not handlers: # pragma: no cover - handlers.append(logging.StreamHandler()) - - logging.basicConfig( - level=level, - format="%(message)s", - handlers=handlers, - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/types.py b/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/types.py deleted file mode 100644 index d6928ca..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/fastmcp/utilities/types.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Common types used across FastMCP.""" - -import base64 -from pathlib import Path - -from mcp.types import AudioContent, ImageContent - - -class Image: - """Helper class for returning images from tools.""" - - def __init__( - self, - path: str | Path | None = None, - data: bytes | None = None, - format: str | None = None, - ): - if path is None and data is None: # pragma: no cover - raise ValueError("Either path or data must be provided") - if path is not None and data is not None: # pragma: no cover - raise ValueError("Only one of path or data can be provided") - - self.path = Path(path) if path else None - self.data = data - self._format = format - self._mime_type = self._get_mime_type() - - def _get_mime_type(self) -> str: - """Get MIME type from format or guess from file extension.""" - if self._format: # pragma: no cover - return f"image/{self._format.lower()}" - - if self.path: - suffix = self.path.suffix.lower() - return { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - }.get(suffix, "application/octet-stream") - return "image/png" # pragma: no cover # default for raw binary data - - def to_image_content(self) -> ImageContent: - """Convert to MCP ImageContent.""" - if self.path: - with open(self.path, "rb") as f: - data = base64.b64encode(f.read()).decode() - elif self.data is not None: # pragma: no cover - data = base64.b64encode(self.data).decode() - else: # pragma: no cover - raise ValueError("No image data available") - - return ImageContent(type="image", data=data, mimeType=self._mime_type) - - -class Audio: - """Helper class for returning audio from tools.""" - - def __init__( - self, - path: str | Path | None = None, - data: bytes | None = None, - format: str | None = None, - ): - if not bool(path) ^ bool(data): # pragma: no cover - raise ValueError("Either path or data can be provided") - - self.path = Path(path) if path else None - self.data = data - self._format = format - self._mime_type = self._get_mime_type() - - def _get_mime_type(self) -> str: - """Get MIME type from format or guess from file extension.""" - if self._format: # pragma: no cover - return f"audio/{self._format.lower()}" - - if self.path: - suffix = self.path.suffix.lower() - return { - ".wav": "audio/wav", - ".mp3": "audio/mpeg", - ".ogg": "audio/ogg", - ".flac": "audio/flac", - ".aac": "audio/aac", - ".m4a": "audio/mp4", - }.get(suffix, "application/octet-stream") - return "audio/wav" # pragma: no cover # default for raw binary data - - def to_audio_content(self) -> AudioContent: - """Convert to MCP AudioContent.""" - if self.path: - with open(self.path, "rb") as f: - data = base64.b64encode(f.read()).decode() - elif self.data is not None: # pragma: no cover - data = base64.b64encode(self.data).decode() - else: # pragma: no cover - raise ValueError("No audio data available") - - return AudioContent(type="audio", data=data, mimeType=self._mime_type) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/__init__.py deleted file mode 100644 index 66df389..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .server import NotificationOptions, Server - -__all__ = ["Server", "NotificationOptions"] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/experimental.py b/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/experimental.py deleted file mode 100644 index 546757b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/experimental.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Experimental handlers for the low-level MCP server. - -WARNING: These APIs are experimental and may change without notice. -""" - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING - -from mcp.server.experimental.task_scope import task_in_session_scope, task_listable_in_session_scope -from mcp.server.experimental.task_support import TaskSupport -from mcp.server.lowlevel.func_inspection import create_call_wrapper -from mcp.shared.exceptions import McpError -from mcp.shared.experimental.tasks.helpers import cancel_task -from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore -from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import ( - INVALID_PARAMS, - CancelTaskRequest, - CancelTaskResult, - ErrorData, - GetTaskPayloadRequest, - GetTaskPayloadResult, - GetTaskRequest, - GetTaskResult, - ListTasksRequest, - ListTasksResult, - ServerCapabilities, - ServerResult, - ServerTasksCapability, - ServerTasksRequestsCapability, - Task, - TasksCallCapability, - TasksCancelCapability, - TasksListCapability, - TasksToolsCapability, -) - -if TYPE_CHECKING: - from mcp.server.lowlevel.server import Server - -logger = logging.getLogger(__name__) - - -class ExperimentalHandlers: - """Experimental request/notification handlers. - - WARNING: These APIs are experimental and may change without notice. - """ - - def __init__( - self, - server: Server, - request_handlers: dict[type, Callable[..., Awaitable[ServerResult]]], - notification_handlers: dict[type, Callable[..., Awaitable[None]]], - ): - self._server = server - self._request_handlers = request_handlers - self._notification_handlers = notification_handlers - self._task_support: TaskSupport | None = None - - @property - def task_support(self) -> TaskSupport | None: - """Get the task support configuration, if enabled.""" - return self._task_support - - def update_capabilities(self, capabilities: ServerCapabilities) -> None: - # Only add tasks capability if handlers are registered - if not any( - req_type in self._request_handlers - for req_type in [GetTaskRequest, ListTasksRequest, CancelTaskRequest, GetTaskPayloadRequest] - ): - return - - capabilities.tasks = ServerTasksCapability() - if ListTasksRequest in self._request_handlers: - capabilities.tasks.list = TasksListCapability() - if CancelTaskRequest in self._request_handlers: - capabilities.tasks.cancel = TasksCancelCapability() - - capabilities.tasks.requests = ServerTasksRequestsCapability( - tools=TasksToolsCapability(call=TasksCallCapability()) - ) # assuming always supported for now - - def enable_tasks( - self, - store: TaskStore | None = None, - queue: TaskMessageQueue | None = None, - ) -> TaskSupport: - """ - Enable experimental task support. - - This sets up the task infrastructure and auto-registers default handlers - for tasks/get, tasks/result, tasks/list, and tasks/cancel. - - Args: - store: Custom TaskStore implementation (defaults to InMemoryTaskStore) - queue: Custom TaskMessageQueue implementation (defaults to InMemoryTaskMessageQueue) - - Returns: - The TaskSupport configuration object - - Example: - # Simple in-memory setup - server.experimental.enable_tasks() - - # Custom store/queue for distributed systems - server.experimental.enable_tasks( - store=RedisTaskStore(redis_url), - queue=RedisTaskMessageQueue(redis_url), - ) - - WARNING: This API is experimental and may change without notice. - """ - if store is None: - store = InMemoryTaskStore() - if queue is None: - queue = InMemoryTaskMessageQueue() - - self._task_support = TaskSupport(store=store, queue=queue) - - # Auto-register default handlers - self._register_default_task_handlers() - - return self._task_support - - def _requestor_session_scope(self) -> str | None: - """Return the task session scope of the session making the current request.""" - session = self._server.request_context.session - return session._experimental.task_session_scope # pyright: ignore[reportPrivateUsage] - - def _require_task_in_requestor_scope(self, task_id: str) -> None: - """Reject task IDs that belong to a different session. - - Task IDs generated by `run_task()` embed the creating session's - scope. The default handlers treat a task created by another session - exactly like a task that does not exist, so a requestor cannot tell - whether such a task exists. Task IDs without an embedded scope are - accepted from any session. - - Raises: - McpError: With INVALID_PARAMS if the task belongs to another session. - """ - if not task_in_session_scope(task_id, self._requestor_session_scope()): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {task_id}", - ) - ) - - def _register_default_task_handlers(self) -> None: - """Register default handlers for task operations. - - Each default handler only operates on tasks created by the requesting - session (see `_require_task_in_requestor_scope`), and tasks/list only - returns the requesting session's own tasks (see - `task_listable_in_session_scope`). - """ - assert self._task_support is not None - support = self._task_support - - # Register get_task handler if not already registered - if GetTaskRequest not in self._request_handlers: - - async def _default_get_task(req: GetTaskRequest) -> ServerResult: - self._require_task_in_requestor_scope(req.params.taskId) - task = await support.store.get_task(req.params.taskId) - if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {req.params.taskId}", - ) - ) - return ServerResult( - GetTaskResult( - taskId=task.taskId, - status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, - ttl=task.ttl, - pollInterval=task.pollInterval, - ) - ) - - self._request_handlers[GetTaskRequest] = _default_get_task - - # Register get_task_result handler if not already registered - if GetTaskPayloadRequest not in self._request_handlers: - - async def _default_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: - self._require_task_in_requestor_scope(req.params.taskId) - ctx = self._server.request_context - result = await support.handler.handle(req, ctx.session, ctx.request_id) - return ServerResult(result) - - self._request_handlers[GetTaskPayloadRequest] = _default_get_task_result - - # Register list_tasks handler if not already registered - if ListTasksRequest not in self._request_handlers: - - async def _default_list_tasks(req: ListTasksRequest) -> ServerResult: - requestor_scope = self._requestor_session_scope() - if requestor_scope is None: - # The server cannot tell this requestor apart from any - # other, so there are no tasks it can be shown. - return ServerResult(ListTasksResult(tasks=[])) - # Return every task that belongs to the requesting session in - # a single page. The store's pagination cursor is never sent - # to the requestor: it is derived from the unfiltered listing, - # so it could identify a task belonging to a different - # session. For the same reason the request's cursor is not - # forwarded to the store. - own_tasks: list[Task] = [] - cursor: str | None = None - while True: - page, cursor = await support.store.list_tasks(cursor) - own_tasks.extend( - task for task in page if task_listable_in_session_scope(task.taskId, requestor_scope) - ) - if cursor is None: - return ServerResult(ListTasksResult(tasks=own_tasks)) - - self._request_handlers[ListTasksRequest] = _default_list_tasks - - # Register cancel_task handler if not already registered - if CancelTaskRequest not in self._request_handlers: - - async def _default_cancel_task(req: CancelTaskRequest) -> ServerResult: - self._require_task_in_requestor_scope(req.params.taskId) - result = await cancel_task(support.store, req.params.taskId) - return ServerResult(result) - - self._request_handlers[CancelTaskRequest] = _default_cancel_task - - def list_tasks( - self, - ) -> Callable[ - [Callable[[ListTasksRequest], Awaitable[ListTasksResult]]], - Callable[[ListTasksRequest], Awaitable[ListTasksResult]], - ]: - """Register a handler for listing tasks. - - WARNING: This API is experimental and may change without notice. - """ - - def decorator( - func: Callable[[ListTasksRequest], Awaitable[ListTasksResult]], - ) -> Callable[[ListTasksRequest], Awaitable[ListTasksResult]]: - logger.debug("Registering handler for ListTasksRequest") - wrapper = create_call_wrapper(func, ListTasksRequest) - - async def handler(req: ListTasksRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) - - self._request_handlers[ListTasksRequest] = handler - return func - - return decorator - - def get_task( - self, - ) -> Callable[ - [Callable[[GetTaskRequest], Awaitable[GetTaskResult]]], Callable[[GetTaskRequest], Awaitable[GetTaskResult]] - ]: - """Register a handler for getting task status. - - WARNING: This API is experimental and may change without notice. - """ - - def decorator( - func: Callable[[GetTaskRequest], Awaitable[GetTaskResult]], - ) -> Callable[[GetTaskRequest], Awaitable[GetTaskResult]]: - logger.debug("Registering handler for GetTaskRequest") - wrapper = create_call_wrapper(func, GetTaskRequest) - - async def handler(req: GetTaskRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) - - self._request_handlers[GetTaskRequest] = handler - return func - - return decorator - - def get_task_result( - self, - ) -> Callable[ - [Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]], - Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]], - ]: - """Register a handler for getting task results/payload. - - WARNING: This API is experimental and may change without notice. - """ - - def decorator( - func: Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]], - ) -> Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]: - logger.debug("Registering handler for GetTaskPayloadRequest") - wrapper = create_call_wrapper(func, GetTaskPayloadRequest) - - async def handler(req: GetTaskPayloadRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) - - self._request_handlers[GetTaskPayloadRequest] = handler - return func - - return decorator - - def cancel_task( - self, - ) -> Callable[ - [Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]], - Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]], - ]: - """Register a handler for cancelling tasks. - - WARNING: This API is experimental and may change without notice. - """ - - def decorator( - func: Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]], - ) -> Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]: - logger.debug("Registering handler for CancelTaskRequest") - wrapper = create_call_wrapper(func, CancelTaskRequest) - - async def handler(req: CancelTaskRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) - - self._request_handlers[CancelTaskRequest] = handler - return func - - return decorator diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/func_inspection.py b/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/func_inspection.py deleted file mode 100644 index 6231aa8..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/func_inspection.py +++ /dev/null @@ -1,54 +0,0 @@ -import inspect -from collections.abc import Callable -from typing import Any, TypeVar, get_type_hints - -T = TypeVar("T") -R = TypeVar("R") - - -def create_call_wrapper(func: Callable[..., R], request_type: type[T]) -> Callable[[T], R]: - """ - Create a wrapper function that knows how to call func with the request object. - - Returns a wrapper function that takes the request and calls func appropriately. - - The wrapper handles three calling patterns: - 1. Positional-only parameter typed as request_type (no default): func(req) - 2. Positional/keyword parameter typed as request_type (no default): func(**{param_name: req}) - 3. No request parameter or parameter with default: func() - """ - try: - sig = inspect.signature(func) - type_hints = get_type_hints(func) - except (ValueError, TypeError, NameError): # pragma: no cover - return lambda _: func() - - # Check for positional-only parameter typed as request_type - for param_name, param in sig.parameters.items(): - if param.kind == inspect.Parameter.POSITIONAL_ONLY: - param_type = type_hints.get(param_name) - if param_type == request_type: # pragma: no branch - # Check if it has a default - if so, treat as old style - if param.default is not inspect.Parameter.empty: # pragma: no cover - return lambda _: func() - # Found positional-only parameter with correct type and no default - return lambda req: func(req) - - # Check for any positional/keyword parameter typed as request_type - for param_name, param in sig.parameters.items(): - if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY): # pragma: no branch - param_type = type_hints.get(param_name) - if param_type == request_type: - # Check if it has a default - if so, treat as old style - if param.default is not inspect.Parameter.empty: # pragma: no cover - return lambda _: func() - - # Found keyword parameter with correct type and no default - # Need to capture param_name in closure properly - def make_keyword_wrapper(name: str) -> Callable[[Any], Any]: - return lambda req: func(**{name: req}) - - return make_keyword_wrapper(param_name) - - # No request parameter found - use old style - return lambda _: func() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/helper_types.py b/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/helper_types.py deleted file mode 100644 index fecc716..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/helper_types.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass -from typing import Any - - -@dataclass -class ReadResourceContents: - """Contents returned from a read_resource call.""" - - content: str | bytes - mime_type: str | None = None - meta: dict[str, Any] | None = None diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/server.py b/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/server.py deleted file mode 100644 index 25a8fde..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/lowlevel/server.py +++ /dev/null @@ -1,830 +0,0 @@ -""" -MCP Server Module - -This module provides a framework for creating an MCP (Model Context Protocol) server. -It allows you to easily define and handle various types of requests and notifications -in an asynchronous manner. - -Usage: -1. Create a Server instance: - server = Server("your_server_name") - -2. Define request handlers using decorators: - @server.list_prompts() - async def handle_list_prompts(request: types.ListPromptsRequest) -> types.ListPromptsResult: - # Implementation - - @server.get_prompt() - async def handle_get_prompt( - name: str, arguments: dict[str, str] | None - ) -> types.GetPromptResult: - # Implementation - - @server.list_tools() - async def handle_list_tools(request: types.ListToolsRequest) -> types.ListToolsResult: - # Implementation - - @server.call_tool() - async def handle_call_tool( - name: str, arguments: dict | None - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - # Implementation - - @server.list_resource_templates() - async def handle_list_resource_templates() -> list[types.ResourceTemplate]: - # Implementation - -3. Define notification handlers if needed: - @server.progress_notification() - async def handle_progress( - progress_token: str | int, progress: float, total: float | None, - message: str | None - ) -> None: - # Implementation - -4. Run the server: - async def main(): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="your_server_name", - server_version="your_version", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - asyncio.run(main()) - -The Server class provides methods to register handlers for various MCP requests and -notifications. It automatically manages the request context and handles incoming -messages from the client. -""" - -from __future__ import annotations as _annotations - -import base64 -import contextvars -import json -import logging -import warnings -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable -from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, Generic, TypeAlias, cast - -import anyio -import jsonschema -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl -from typing_extensions import TypeVar, deprecated - -import mcp.types as types -from mcp.server.experimental.request_context import Experimental -from mcp.server.lowlevel.experimental import ExperimentalHandlers -from mcp.server.lowlevel.func_inspection import create_call_wrapper -from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError -from mcp.shared.message import ServerMessageMetadata, SessionMessage -from mcp.shared.session import RequestResponder -from mcp.shared.tool_name_validation import validate_and_warn_tool_name - -logger = logging.getLogger(__name__) - -LifespanResultT = TypeVar("LifespanResultT", default=Any) -RequestT = TypeVar("RequestT", default=Any) - -# type aliases for tool call results -StructuredContent: TypeAlias = dict[str, Any] -UnstructuredContent: TypeAlias = Iterable[types.ContentBlock] -CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent] - -# This will be properly typed in each Server instance's context -request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx") - - -class NotificationOptions: - def __init__( - self, - prompts_changed: bool = False, - resources_changed: bool = False, - tools_changed: bool = False, - ): - self.prompts_changed = prompts_changed - self.resources_changed = resources_changed - self.tools_changed = tools_changed - - -@asynccontextmanager -async def lifespan(_: Server[LifespanResultT, RequestT]) -> AsyncIterator[dict[str, Any]]: - """Default lifespan context manager that does nothing. - - Args: - server: The server instance this lifespan is managing - - Returns: - An empty context object - """ - yield {} - - -class Server(Generic[LifespanResultT, RequestT]): - def __init__( - self, - name: str, - version: str | None = None, - instructions: str | None = None, - website_url: str | None = None, - icons: list[types.Icon] | None = None, - lifespan: Callable[ - [Server[LifespanResultT, RequestT]], - AbstractAsyncContextManager[LifespanResultT], - ] = lifespan, - ): - self.name = name - self.version = version - self.instructions = instructions - self.website_url = website_url - self.icons = icons - self.lifespan = lifespan - self.request_handlers: dict[type, Callable[..., Awaitable[types.ServerResult]]] = { - types.PingRequest: _ping_handler, - } - self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {} - self._tool_cache: dict[str, types.Tool] = {} - self._experimental_handlers: ExperimentalHandlers | None = None - logger.debug("Initializing server %r", name) - - def create_initialization_options( - self, - notification_options: NotificationOptions | None = None, - experimental_capabilities: dict[str, dict[str, Any]] | None = None, - ) -> InitializationOptions: - """Create initialization options from this server instance.""" - - def pkg_version(package: str) -> str: - try: - from importlib.metadata import version - - return version(package) - except Exception: # pragma: no cover - pass - - return "unknown" # pragma: no cover - - return InitializationOptions( - server_name=self.name, - server_version=self.version if self.version else pkg_version("mcp"), - capabilities=self.get_capabilities( - notification_options or NotificationOptions(), - experimental_capabilities or {}, - ), - instructions=self.instructions, - website_url=self.website_url, - icons=self.icons, - ) - - def get_capabilities( - self, - notification_options: NotificationOptions, - experimental_capabilities: dict[str, dict[str, Any]], - ) -> types.ServerCapabilities: - """Convert existing handlers to a ServerCapabilities object.""" - prompts_capability = None - resources_capability = None - tools_capability = None - logging_capability = None - completions_capability = None - - # Set prompt capabilities if handler exists - if types.ListPromptsRequest in self.request_handlers: - prompts_capability = types.PromptsCapability(listChanged=notification_options.prompts_changed) - - # Set resource capabilities if handler exists - if types.ListResourcesRequest in self.request_handlers: - resources_capability = types.ResourcesCapability( - subscribe=False, listChanged=notification_options.resources_changed - ) - - # Set tool capabilities if handler exists - if types.ListToolsRequest in self.request_handlers: - tools_capability = types.ToolsCapability(listChanged=notification_options.tools_changed) - - # Set logging capabilities if handler exists - if types.SetLevelRequest in self.request_handlers: # pragma: no cover - logging_capability = types.LoggingCapability() - - # Set completions capabilities if handler exists - if types.CompleteRequest in self.request_handlers: - completions_capability = types.CompletionsCapability() - - capabilities = types.ServerCapabilities( - prompts=prompts_capability, - resources=resources_capability, - tools=tools_capability, - logging=logging_capability, - experimental=experimental_capabilities, - completions=completions_capability, - ) - if self._experimental_handlers: - self._experimental_handlers.update_capabilities(capabilities) - return capabilities - - @property - def request_context( - self, - ) -> RequestContext[ServerSession, LifespanResultT, RequestT]: - """If called outside of a request context, this will raise a LookupError.""" - return request_ctx.get() - - @property - @deprecated( - "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" - " from the MCP specification and are expected to return as a separate MCP extension." - ) - def experimental(self) -> ExperimentalHandlers: - """Experimental APIs for tasks and other features. - - Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks - (SEP-1686) were removed from the MCP specification and are expected to - return as a separate MCP extension. - """ - - # We create this inline so we only add these capabilities _if_ they're actually used - if self._experimental_handlers is None: - self._experimental_handlers = ExperimentalHandlers(self, self.request_handlers, self.notification_handlers) - return self._experimental_handlers - - def list_prompts(self): - def decorator( - func: Callable[[], Awaitable[list[types.Prompt]]] - | Callable[[types.ListPromptsRequest], Awaitable[types.ListPromptsResult]], - ): - logger.debug("Registering handler for PromptListRequest") - - wrapper = create_call_wrapper(func, types.ListPromptsRequest) - - async def handler(req: types.ListPromptsRequest): - result = await wrapper(req) - # Handle both old style (list[Prompt]) and new style (ListPromptsResult) - if isinstance(result, types.ListPromptsResult): - return types.ServerResult(result) - else: - # Old style returns list[Prompt] - return types.ServerResult(types.ListPromptsResult(prompts=result)) - - self.request_handlers[types.ListPromptsRequest] = handler - return func - - return decorator - - def get_prompt(self): - def decorator( - func: Callable[[str, dict[str, str] | None], Awaitable[types.GetPromptResult]], - ): - logger.debug("Registering handler for GetPromptRequest") - - async def handler(req: types.GetPromptRequest): - prompt_get = await func(req.params.name, req.params.arguments) - return types.ServerResult(prompt_get) - - self.request_handlers[types.GetPromptRequest] = handler - return func - - return decorator - - def list_resources(self): - def decorator( - func: Callable[[], Awaitable[list[types.Resource]]] - | Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]], - ): - logger.debug("Registering handler for ListResourcesRequest") - - wrapper = create_call_wrapper(func, types.ListResourcesRequest) - - async def handler(req: types.ListResourcesRequest): - result = await wrapper(req) - # Handle both old style (list[Resource]) and new style (ListResourcesResult) - if isinstance(result, types.ListResourcesResult): - return types.ServerResult(result) - else: - # Old style returns list[Resource] - return types.ServerResult(types.ListResourcesResult(resources=result)) - - self.request_handlers[types.ListResourcesRequest] = handler - return func - - return decorator - - def list_resource_templates(self): - def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): - logger.debug("Registering handler for ListResourceTemplatesRequest") - - async def handler(_: Any): - templates = await func() - return types.ServerResult(types.ListResourceTemplatesResult(resourceTemplates=templates)) - - self.request_handlers[types.ListResourceTemplatesRequest] = handler - return func - - return decorator - - def read_resource(self): - def decorator( - func: Callable[[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]], - ): - logger.debug("Registering handler for ReadResourceRequest") - - async def handler(req: types.ReadResourceRequest): - result = await func(req.params.uri) - - def create_content(data: str | bytes, mime_type: str | None, meta: dict[str, Any] | None = None): - # Note: ResourceContents uses Field(alias="_meta"), so we must use the alias key - meta_kwargs: dict[str, Any] = {"_meta": meta} if meta is not None else {} - match data: - case str() as data: - return types.TextResourceContents( - uri=req.params.uri, - text=data, - mimeType=mime_type or "text/plain", - **meta_kwargs, - ) - case bytes() as data: # pragma: no cover - return types.BlobResourceContents( - uri=req.params.uri, - blob=base64.b64encode(data).decode(), - mimeType=mime_type or "application/octet-stream", - **meta_kwargs, - ) - - match result: - case str() | bytes() as data: # pragma: no cover - warnings.warn( - "Returning str or bytes from read_resource is deprecated. " - "Use Iterable[ReadResourceContents] instead.", - DeprecationWarning, - stacklevel=2, - ) - content = create_content(data, None) - case Iterable() as contents: - contents_list = [ - create_content( - content_item.content, content_item.mime_type, getattr(content_item, "meta", None) - ) - for content_item in contents - ] - return types.ServerResult( - types.ReadResourceResult( - contents=contents_list, - ) - ) - case _: # pragma: no cover - raise ValueError(f"Unexpected return type from read_resource: {type(result)}") - - return types.ServerResult( # pragma: no cover - types.ReadResourceResult( - contents=[content], - ) - ) - - self.request_handlers[types.ReadResourceRequest] = handler - return func - - return decorator - - def set_logging_level(self): # pragma: no cover - def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]): - logger.debug("Registering handler for SetLevelRequest") - - async def handler(req: types.SetLevelRequest): - await func(req.params.level) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.SetLevelRequest] = handler - return func - - return decorator - - def subscribe_resource(self): # pragma: no cover - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): - logger.debug("Registering handler for SubscribeRequest") - - async def handler(req: types.SubscribeRequest): - await func(req.params.uri) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.SubscribeRequest] = handler - return func - - return decorator - - def unsubscribe_resource(self): # pragma: no cover - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): - logger.debug("Registering handler for UnsubscribeRequest") - - async def handler(req: types.UnsubscribeRequest): - await func(req.params.uri) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.UnsubscribeRequest] = handler - return func - - return decorator - - def list_tools(self): - def decorator( - func: Callable[[], Awaitable[list[types.Tool]]] - | Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]], - ): - logger.debug("Registering handler for ListToolsRequest") - - wrapper = create_call_wrapper(func, types.ListToolsRequest) - - async def handler(req: types.ListToolsRequest): - result = await wrapper(req) - - # Handle both old style (list[Tool]) and new style (ListToolsResult) - if isinstance(result, types.ListToolsResult): # pragma: no cover - # Refresh the tool cache with returned tools - for tool in result.tools: - validate_and_warn_tool_name(tool.name) - self._tool_cache[tool.name] = tool - return types.ServerResult(result) - else: - # Old style returns list[Tool] - # Clear and refresh the entire tool cache - self._tool_cache.clear() - for tool in result: - validate_and_warn_tool_name(tool.name) - self._tool_cache[tool.name] = tool - return types.ServerResult(types.ListToolsResult(tools=result)) - - self.request_handlers[types.ListToolsRequest] = handler - return func - - return decorator - - def _make_error_result(self, error_message: str) -> types.ServerResult: - """Create a ServerResult with an error CallToolResult.""" - return types.ServerResult( - types.CallToolResult( - content=[types.TextContent(type="text", text=error_message)], - isError=True, - ) - ) - - async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None: - """Get tool definition from cache, refreshing if necessary. - - Returns the Tool object if found, None otherwise. - """ - if tool_name not in self._tool_cache: - if types.ListToolsRequest in self.request_handlers: - logger.debug("Tool cache miss for %s, refreshing cache", tool_name) - await self.request_handlers[types.ListToolsRequest](None) - - tool = self._tool_cache.get(tool_name) - if tool is None: - logger.warning("Tool '%s' not listed, no validation will be performed", tool_name) - - return tool - - def call_tool(self, *, validate_input: bool = True): - """Register a tool call handler. - - Args: - validate_input: If True, validates input against inputSchema. Default is True. - - The handler validates input against inputSchema (if validate_input=True), calls the tool function, - and builds a CallToolResult with the results: - - Unstructured content (iterable of ContentBlock): returned in content - - Structured content (dict): returned in structuredContent, serialized JSON text returned in content - - Both: returned in content and structuredContent - - If outputSchema is defined, validates structuredContent or errors if missing. - """ - - def decorator( - func: Callable[ - ..., - Awaitable[ - UnstructuredContent - | StructuredContent - | CombinationContent - | types.CallToolResult - | types.CreateTaskResult - ], - ], - ): - logger.debug("Registering handler for CallToolRequest") - - async def handler(req: types.CallToolRequest): - try: - tool_name = req.params.name - arguments = req.params.arguments or {} - tool = await self._get_cached_tool_definition(tool_name) - - # input validation - if validate_input and tool: - try: - jsonschema.validate(instance=arguments, schema=tool.inputSchema) - except jsonschema.ValidationError as e: - return self._make_error_result(f"Input validation error: {e.message}") - - # tool call - results = await func(tool_name, arguments) - - # output normalization - unstructured_content: UnstructuredContent - maybe_structured_content: StructuredContent | None - if isinstance(results, types.CallToolResult): - return types.ServerResult(results) - elif isinstance(results, types.CreateTaskResult): - # Task-augmented execution returns task info instead of result - return types.ServerResult(results) - elif isinstance(results, tuple) and len(results) == 2: - # tool returned both structured and unstructured content - unstructured_content, maybe_structured_content = cast(CombinationContent, results) - elif isinstance(results, dict): - # tool returned structured content only - maybe_structured_content = cast(StructuredContent, results) - unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))] - elif hasattr(results, "__iter__"): # pragma: no cover - # tool returned unstructured content only - unstructured_content = cast(UnstructuredContent, results) - maybe_structured_content = None - else: # pragma: no cover - return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}") - - # output validation - if tool and tool.outputSchema is not None: - if maybe_structured_content is None: - return self._make_error_result( - "Output validation error: outputSchema defined but no structured output returned" - ) - else: - try: - jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema) - except jsonschema.ValidationError as e: - return self._make_error_result(f"Output validation error: {e.message}") - - # result - return types.ServerResult( - types.CallToolResult( - content=list(unstructured_content), - structuredContent=maybe_structured_content, - isError=False, - ) - ) - except UrlElicitationRequiredError: - # Re-raise UrlElicitationRequiredError so it can be properly handled - # by _handle_request, which converts it to an error response with code -32042 - raise - except Exception as e: - return self._make_error_result(str(e)) - - self.request_handlers[types.CallToolRequest] = handler - return func - - return decorator - - def progress_notification(self): - def decorator( - func: Callable[[str | int, float, float | None, str | None], Awaitable[None]], - ): - logger.debug("Registering handler for ProgressNotification") - - async def handler(req: types.ProgressNotification): - await func( - req.params.progressToken, - req.params.progress, - req.params.total, - req.params.message, - ) - - self.notification_handlers[types.ProgressNotification] = handler - return func - - return decorator - - def completion(self): - """Provides completions for prompts and resource templates""" - - def decorator( - func: Callable[ - [ - types.PromptReference | types.ResourceTemplateReference, - types.CompletionArgument, - types.CompletionContext | None, - ], - Awaitable[types.Completion | None], - ], - ): - logger.debug("Registering handler for CompleteRequest") - - async def handler(req: types.CompleteRequest): - completion = await func(req.params.ref, req.params.argument, req.params.context) - return types.ServerResult( - types.CompleteResult( - completion=completion - if completion is not None - else types.Completion(values=[], total=None, hasMore=None), - ) - ) - - self.request_handlers[types.CompleteRequest] = handler - return func - - return decorator - - async def run( - self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], - initialization_options: InitializationOptions, - # When False, exceptions are returned as messages to the client. - # When True, exceptions are raised, which will cause the server to shut down - # but also make tracing exceptions much easier during testing and when using - # in-process servers. - raise_exceptions: bool = False, - # When True, the server is stateless and - # clients can perform initialization with any node. The client must still follow - # the initialization lifecycle, but can do so with any available node - # rather than requiring initialization for each connection. - stateless: bool = False, - ): - async with AsyncExitStack() as stack: - lifespan_context = await stack.enter_async_context(self.lifespan(self)) - session = await stack.enter_async_context( - ServerSession( - read_stream, - write_stream, - initialization_options, - stateless=stateless, - ) - ) - - # Configure task support for this session if enabled - task_support = self._experimental_handlers.task_support if self._experimental_handlers else None - if task_support is not None: - task_support.configure_session(session, stateless=stateless) - await stack.enter_async_context(task_support.run()) - - async with anyio.create_task_group() as tg: - try: - async for message in session.incoming_messages: - logger.debug("Received message: %s", message) - - tg.start_soon( - self._handle_message, - message, - session, - lifespan_context, - raise_exceptions, - ) - finally: - # Transport closed: cancel in-flight handlers. Without this the - # TG join waits for them, and when they eventually try to - # respond they hit a closed write stream (the session's - # _receive_loop closed it when the read stream ended). - tg.cancel_scope.cancel() - - async def _handle_message( - self, - message: RequestResponder[types.ClientRequest, types.ServerResult] | types.ClientNotification | Exception, - session: ServerSession, - lifespan_context: LifespanResultT, - raise_exceptions: bool = False, - ): - with warnings.catch_warnings(record=True) as w: - match message: - case RequestResponder(request=types.ClientRequest(root=req)) as responder: - with responder: - await self._handle_request(message, req, session, lifespan_context, raise_exceptions) - case types.ClientNotification(root=notify): - await self._handle_notification(notify) - case Exception(): # pragma: no cover - logger.error(f"Received exception from stream: {message}") - await session.send_log_message( - level="error", - data="Internal Server Error", - logger="mcp.server.exception_handler", - ) - if raise_exceptions: - raise message - - for warning in w: # pragma: no cover - logger.info("Warning: %s: %s", warning.category.__name__, warning.message) - - async def _handle_request( - self, - message: RequestResponder[types.ClientRequest, types.ServerResult], - req: types.ClientRequestType, - session: ServerSession, - lifespan_context: LifespanResultT, - raise_exceptions: bool, - ): - logger.info("Processing request of type %s", type(req).__name__) - - if handler := self.request_handlers.get(type(req)): - logger.debug("Dispatching request of type %s", type(req).__name__) - - token = None - try: - # Extract request context and close_sse_stream from message metadata - request_data = None - close_sse_stream_cb = None - close_standalone_sse_stream_cb = None - if message.message_metadata is not None and isinstance( - message.message_metadata, ServerMessageMetadata - ): # pragma: no cover - request_data = message.message_metadata.request_context - close_sse_stream_cb = message.message_metadata.close_sse_stream - close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream - - # Set our global state that can be retrieved via - # app.get_request_context() - client_capabilities = session.client_params.capabilities if session.client_params else None - task_support = self._experimental_handlers.task_support if self._experimental_handlers else None - # Get task metadata from request params if present - task_metadata = None - if hasattr(req, "params") and req.params is not None: - task_metadata = getattr(req.params, "task", None) - token = request_ctx.set( - RequestContext( - message.request_id, - message.request_meta, - session, - lifespan_context, - Experimental( - task_metadata=task_metadata, - _client_capabilities=client_capabilities, - _session=session, - _task_support=task_support, - ), - request=request_data, - close_sse_stream=close_sse_stream_cb, - close_standalone_sse_stream=close_standalone_sse_stream_cb, - ) - ) - response = await handler(req) - except McpError as err: # pragma: no cover - response = err.error - except anyio.get_cancelled_exc_class(): - if message.cancelled: - # Client sent CancelledNotification; responder.cancel() already - # sent an error response, so skip the duplicate. - logger.info( - "Request %s cancelled - duplicate response suppressed", - message.request_id, - ) - return - # Transport-close cancellation from the TG in run(); re-raise so the - # TG swallows its own cancellation. - raise - except Exception as err: # pragma: no cover - if raise_exceptions: - raise err - response = types.ErrorData(code=0, message=str(err), data=None) - finally: - # Reset the global state after we are done - if token is not None: # pragma: no branch - request_ctx.reset(token) - else: # pragma: no cover - response = types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="Method not found", - ) - - try: - await message.respond(response) - except (anyio.BrokenResourceError, anyio.ClosedResourceError): - # Transport closed between handler unblocking and respond. Happens - # when _receive_loop's finally wakes a handler blocked on - # send_request: the handler runs to respond() before run()'s TG - # cancel fires, but after the write stream closed. Closed if our - # end closed (_receive_loop's async-with exit); Broken if the peer - # end closed first (streamable_http terminate()). - logger.debug("Response for %s dropped - transport closed", message.request_id) - return - - logger.debug("Response sent") - - async def _handle_notification(self, notify: Any): - if handler := self.notification_handlers.get(type(notify)): # type: ignore - logger.debug("Dispatching notification of type %s", type(notify).__name__) - - try: - await handler(notify) - except Exception: # pragma: no cover - logger.exception("Uncaught exception in notification handler") - - -async def _ping_handler(request: types.PingRequest) -> types.ServerResult: - return types.ServerResult(types.EmptyResult()) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/models.py b/src/agent_workflow/mcp/sdk/src/mcp/server/models.py deleted file mode 100644 index ddf716c..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/models.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -This module provides simpler types to use with the server for managing prompts -and tools. -""" - -from pydantic import BaseModel - -from mcp.types import ( - Icon, - ServerCapabilities, -) - - -class InitializationOptions(BaseModel): - server_name: str - server_version: str - capabilities: ServerCapabilities - instructions: str | None = None - website_url: str | None = None - icons: list[Icon] | None = None diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/session.py b/src/agent_workflow/mcp/sdk/src/mcp/server/session.py deleted file mode 100644 index f80971b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/session.py +++ /dev/null @@ -1,701 +0,0 @@ -""" -ServerSession Module - -This module provides the ServerSession class, which manages communication between the -server and client in the MCP (Model Context Protocol) framework. It is most commonly -used in MCP servers to interact with the client. - -Common usage pattern: -``` - server = Server(name) - - @server.call_tool() - async def handle_tool_call(ctx: RequestContext, arguments: dict[str, Any]) -> Any: - # Check client capabilities before proceeding - if ctx.session.check_client_capability( - types.ClientCapabilities(experimental={"advanced_tools": dict()}) - ): - # Perform advanced tool operations - result = await perform_advanced_tool_operation(arguments) - else: - # Fall back to basic tool operations - result = await perform_basic_tool_operation(arguments) - - return result - - @server.list_prompts() - async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: - # Access session for any necessary checks or operations - if ctx.session.client_params: - # Customize prompts based on client initialization parameters - return generate_custom_prompts(ctx.session.client_params) - else: - return default_prompts -``` - -The ServerSession class is typically used internally by the Server class and should not -be instantiated directly by users of the MCP framework. -""" - -from enum import Enum -from typing import Any, TypeVar, overload - -import anyio -import anyio.lowlevel -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl -from typing_extensions import deprecated - -import mcp.types as types -from mcp.server.experimental.session_features import ExperimentalServerSessionFeatures -from mcp.server.models import InitializationOptions -from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages -from mcp.shared.experimental.tasks.capabilities import check_tasks_capability -from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY -from mcp.shared.message import ServerMessageMetadata, SessionMessage -from mcp.shared.session import ( - BaseSession, - RequestResponder, -) -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS - - -class InitializationState(Enum): - NotInitialized = 1 - Initializing = 2 - Initialized = 3 - - -ServerSessionT = TypeVar("ServerSessionT", bound="ServerSession") - -ServerRequestResponder = ( - RequestResponder[types.ClientRequest, types.ServerResult] | types.ClientNotification | Exception -) - - -class ServerSession( - BaseSession[ - types.ServerRequest, - types.ServerNotification, - types.ServerResult, - types.ClientRequest, - types.ClientNotification, - ] -): - _initialized: InitializationState = InitializationState.NotInitialized - _client_params: types.InitializeRequestParams | None = None - _experimental_features: ExperimentalServerSessionFeatures | None = None - - def __init__( - self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], - init_options: InitializationOptions, - stateless: bool = False, - ) -> None: - super().__init__(read_stream, write_stream, types.ClientRequest, types.ClientNotification) - self._initialization_state = ( - InitializationState.Initialized if stateless else InitializationState.NotInitialized - ) - - self._init_options = init_options - self._incoming_message_stream_writer, self._incoming_message_stream_reader = anyio.create_memory_object_stream[ - ServerRequestResponder - ](0) - self._exit_stack.push_async_callback(lambda: self._incoming_message_stream_reader.aclose()) - - @property - def client_params(self) -> types.InitializeRequestParams | None: - return self._client_params # pragma: no cover - - @property - def _experimental(self) -> ExperimentalServerSessionFeatures: - """Internal accessor for experimental features that skips the deprecation warning.""" - if self._experimental_features is None: - self._experimental_features = ExperimentalServerSessionFeatures(self) - return self._experimental_features - - @property - @deprecated( - "The experimental tasks API is deprecated and will be removed in mcp 2.0: tasks (SEP-1686) were removed" - " from the MCP specification and are expected to return as a separate MCP extension." - ) - def experimental(self) -> ExperimentalServerSessionFeatures: - """Experimental APIs for server→client task operations. - - Deprecated: the experimental tasks API will be removed in mcp 2.0. Tasks - (SEP-1686) were removed from the MCP specification and are expected to - return as a separate MCP extension. - """ - return self._experimental - - def check_client_capability(self, capability: types.ClientCapabilities) -> bool: # pragma: no cover - """Check if the client supports a specific capability.""" - if self._client_params is None: - return False - - client_caps = self._client_params.capabilities - - if capability.roots is not None: - if client_caps.roots is None: - return False - if capability.roots.listChanged and not client_caps.roots.listChanged: - return False - - if capability.sampling is not None: - if client_caps.sampling is None: - return False - if capability.sampling.context is not None and client_caps.sampling.context is None: - return False - if capability.sampling.tools is not None and client_caps.sampling.tools is None: - return False - - if capability.elicitation is not None and client_caps.elicitation is None: - return False - - if capability.experimental is not None: - if client_caps.experimental is None: - return False - for exp_key, exp_value in capability.experimental.items(): - if exp_key not in client_caps.experimental or client_caps.experimental[exp_key] != exp_value: - return False - - if capability.tasks is not None: - if client_caps.tasks is None: - return False - if not check_tasks_capability(capability.tasks, client_caps.tasks): - return False - - return True - - async def _receive_loop(self) -> None: - async with self._incoming_message_stream_writer: - await super()._receive_loop() - - async def _received_request(self, responder: RequestResponder[types.ClientRequest, types.ServerResult]): - match responder.request.root: - case types.InitializeRequest(params=params): - requested_version = params.protocolVersion - self._initialization_state = InitializationState.Initializing - self._client_params = params - with responder: - await responder.respond( - types.ServerResult( - types.InitializeResult( - protocolVersion=requested_version - if requested_version in SUPPORTED_PROTOCOL_VERSIONS - else types.LATEST_PROTOCOL_VERSION, - capabilities=self._init_options.capabilities, - serverInfo=types.Implementation( - name=self._init_options.server_name, - version=self._init_options.server_version, - websiteUrl=self._init_options.website_url, - icons=self._init_options.icons, - ), - instructions=self._init_options.instructions, - ) - ) - ) - self._initialization_state = InitializationState.Initialized - case types.PingRequest(): - # Ping requests are allowed at any time - pass - case _: - if self._initialization_state != InitializationState.Initialized: - raise RuntimeError("Received request before initialization was complete") - - async def _received_notification(self, notification: types.ClientNotification) -> None: - # Need this to avoid ASYNC910 - await anyio.lowlevel.checkpoint() - match notification.root: - case types.InitializedNotification(): - self._initialization_state = InitializationState.Initialized - case _: - if self._initialization_state != InitializationState.Initialized: # pragma: no cover - raise RuntimeError("Received notification before initialization was complete") - - async def send_log_message( - self, - level: types.LoggingLevel, - data: Any, - logger: str | None = None, - related_request_id: types.RequestId | None = None, - ) -> None: - """Send a log message notification.""" - await self.send_notification( - types.ServerNotification( - types.LoggingMessageNotification( - params=types.LoggingMessageNotificationParams( - level=level, - data=data, - logger=logger, - ), - ) - ), - related_request_id, - ) - - async def send_resource_updated(self, uri: AnyUrl) -> None: # pragma: no cover - """Send a resource updated notification.""" - await self.send_notification( - types.ServerNotification( - types.ResourceUpdatedNotification( - params=types.ResourceUpdatedNotificationParams(uri=uri), - ) - ) - ) - - @overload - async def create_message( - self, - messages: list[types.SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - tools: None = None, - tool_choice: types.ToolChoice | None = None, - related_request_id: types.RequestId | None = None, - ) -> types.CreateMessageResult: - """Overload: Without tools, returns single content.""" - ... - - @overload - async def create_message( - self, - messages: list[types.SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - tools: list[types.Tool], - tool_choice: types.ToolChoice | None = None, - related_request_id: types.RequestId | None = None, - ) -> types.CreateMessageResultWithTools: - """Overload: With tools, returns array-capable content.""" - ... - - async def create_message( - self, - messages: list[types.SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - tools: list[types.Tool] | None = None, - tool_choice: types.ToolChoice | None = None, - related_request_id: types.RequestId | None = None, - ) -> types.CreateMessageResult | types.CreateMessageResultWithTools: - """Send a sampling/create_message request. - - Args: - messages: The conversation messages to send. - max_tokens: Maximum number of tokens to generate. - system_prompt: Optional system prompt. - include_context: Optional context inclusion setting. - Should only be set to "thisServer" or "allServers" - if the client has sampling.context capability. - temperature: Optional sampling temperature. - stop_sequences: Optional stop sequences. - metadata: Optional metadata to pass through to the LLM provider. - model_preferences: Optional model selection preferences. - tools: Optional list of tools the LLM can use during sampling. - Requires client to have sampling.tools capability. - tool_choice: Optional control over tool usage behavior. - Requires client to have sampling.tools capability. - related_request_id: Optional ID of a related request. - - Returns: - The sampling result from the client. - - Raises: - McpError: If tools are provided but client doesn't support them. - ValueError: If tool_use or tool_result message structure is invalid. - """ - client_caps = self._client_params.capabilities if self._client_params else None - validate_sampling_tools(client_caps, tools, tool_choice) - validate_tool_use_result_messages(messages) - - request = types.ServerRequest( - types.CreateMessageRequest( - params=types.CreateMessageRequestParams( - messages=messages, - systemPrompt=system_prompt, - includeContext=include_context, - temperature=temperature, - maxTokens=max_tokens, - stopSequences=stop_sequences, - metadata=metadata, - modelPreferences=model_preferences, - tools=tools, - toolChoice=tool_choice, - ), - ) - ) - metadata_obj = ServerMessageMetadata(related_request_id=related_request_id) - - # Use different result types based on whether tools are provided - if tools is not None: - return await self.send_request( - request=request, - result_type=types.CreateMessageResultWithTools, - metadata=metadata_obj, - ) - return await self.send_request( - request=request, - result_type=types.CreateMessageResult, - metadata=metadata_obj, - ) - - async def list_roots(self) -> types.ListRootsResult: - """Send a roots/list request.""" - return await self.send_request( - types.ServerRequest(types.ListRootsRequest()), - types.ListRootsResult, - ) - - async def elicit( - self, - message: str, - requestedSchema: types.ElicitRequestedSchema, - related_request_id: types.RequestId | None = None, - ) -> types.ElicitResult: - """Send a form mode elicitation/create request. - - Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - related_request_id: Optional ID of the request that triggered this elicitation - - Returns: - The client's response - - Note: - This method is deprecated in favor of elicit_form(). It remains for - backward compatibility but new code should use elicit_form(). - """ - return await self.elicit_form(message, requestedSchema, related_request_id) - - async def elicit_form( - self, - message: str, - requestedSchema: types.ElicitRequestedSchema, - related_request_id: types.RequestId | None = None, - ) -> types.ElicitResult: - """Send a form mode elicitation/create request. - - Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - related_request_id: Optional ID of the request that triggered this elicitation - - Returns: - The client's response with form data - """ - return await self.send_request( - types.ServerRequest( - types.ElicitRequest( - params=types.ElicitRequestFormParams( - message=message, - requestedSchema=requestedSchema, - ), - ) - ), - types.ElicitResult, - metadata=ServerMessageMetadata(related_request_id=related_request_id), - ) - - async def elicit_url( - self, - message: str, - url: str, - elicitation_id: str, - related_request_id: types.RequestId | None = None, - ) -> types.ElicitResult: - """Send a URL mode elicitation/create request. - - This directs the user to an external URL for out-of-band interactions - like OAuth flows, credential collection, or payment processing. - - Args: - message: Human-readable explanation of why the interaction is needed - url: The URL the user should navigate to - elicitation_id: Unique identifier for tracking this elicitation - related_request_id: Optional ID of the request that triggered this elicitation - - Returns: - The client's response indicating acceptance, decline, or cancellation - """ - return await self.send_request( - types.ServerRequest( - types.ElicitRequest( - params=types.ElicitRequestURLParams( - message=message, - url=url, - elicitationId=elicitation_id, - ), - ) - ), - types.ElicitResult, - metadata=ServerMessageMetadata(related_request_id=related_request_id), - ) - - async def send_ping(self) -> types.EmptyResult: # pragma: no cover - """Send a ping request.""" - return await self.send_request( - types.ServerRequest(types.PingRequest()), - types.EmptyResult, - ) - - async def send_progress_notification( - self, - progress_token: str | int, - progress: float, - total: float | None = None, - message: str | None = None, - related_request_id: str | None = None, - ) -> None: - """Send a progress notification.""" - await self.send_notification( - types.ServerNotification( - types.ProgressNotification( - params=types.ProgressNotificationParams( - progressToken=progress_token, - progress=progress, - total=total, - message=message, - ), - ) - ), - related_request_id, - ) - - async def send_resource_list_changed(self) -> None: # pragma: no cover - """Send a resource list changed notification.""" - await self.send_notification(types.ServerNotification(types.ResourceListChangedNotification())) - - async def send_tool_list_changed(self) -> None: # pragma: no cover - """Send a tool list changed notification.""" - await self.send_notification(types.ServerNotification(types.ToolListChangedNotification())) - - async def send_prompt_list_changed(self) -> None: # pragma: no cover - """Send a prompt list changed notification.""" - await self.send_notification(types.ServerNotification(types.PromptListChangedNotification())) - - async def send_elicit_complete( - self, - elicitation_id: str, - related_request_id: types.RequestId | None = None, - ) -> None: - """Send an elicitation completion notification. - - This should be sent when a URL mode elicitation has been completed - out-of-band to inform the client that it may retry any requests - that were waiting for this elicitation. - - Args: - elicitation_id: The unique identifier of the completed elicitation - related_request_id: Optional ID of the request that triggered this - """ - await self.send_notification( - types.ServerNotification( - types.ElicitCompleteNotification( - params=types.ElicitCompleteNotificationParams(elicitationId=elicitation_id) - ) - ), - related_request_id, - ) - - def _build_elicit_form_request( - self, - message: str, - requestedSchema: types.ElicitRequestedSchema, - related_task_id: str | None = None, - task: types.TaskMetadata | None = None, - ) -> types.JSONRPCRequest: - """Build a form mode elicitation request without sending it. - - Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata - task: If provided, makes this a task-augmented request - - Returns: - A JSONRPCRequest ready to be sent or queued - """ - params = types.ElicitRequestFormParams( - message=message, - requestedSchema=requestedSchema, - task=task, - ) - params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) - - # Add related-task metadata if associated with a parent task - if related_task_id is not None: - # Defensive: model_dump() never includes _meta, but guard against future changes - if "_meta" not in params_data: # pragma: no cover - params_data["_meta"] = {} - params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( - taskId=related_task_id - ).model_dump(by_alias=True) - - request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id - if related_task_id is None: - self._request_id += 1 - - return types.JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - method="elicitation/create", - params=params_data, - ) - - def _build_elicit_url_request( - self, - message: str, - url: str, - elicitation_id: str, - related_task_id: str | None = None, - ) -> types.JSONRPCRequest: - """Build a URL mode elicitation request without sending it. - - Args: - message: Human-readable explanation of why the interaction is needed - url: The URL the user should navigate to - elicitation_id: Unique identifier for tracking this elicitation - related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata - - Returns: - A JSONRPCRequest ready to be sent or queued - """ - params = types.ElicitRequestURLParams( - message=message, - url=url, - elicitationId=elicitation_id, - ) - params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) - - # Add related-task metadata if associated with a parent task - if related_task_id is not None: - # Defensive: model_dump() never includes _meta, but guard against future changes - if "_meta" not in params_data: # pragma: no cover - params_data["_meta"] = {} - params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( - taskId=related_task_id - ).model_dump(by_alias=True) - - request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id - if related_task_id is None: - self._request_id += 1 - - return types.JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - method="elicitation/create", - params=params_data, - ) - - def _build_create_message_request( - self, - messages: list[types.SamplingMessage], - *, - max_tokens: int, - system_prompt: str | None = None, - include_context: types.IncludeContext | None = None, - temperature: float | None = None, - stop_sequences: list[str] | None = None, - metadata: dict[str, Any] | None = None, - model_preferences: types.ModelPreferences | None = None, - tools: list[types.Tool] | None = None, - tool_choice: types.ToolChoice | None = None, - related_task_id: str | None = None, - task: types.TaskMetadata | None = None, - ) -> types.JSONRPCRequest: - """Build a sampling/createMessage request without sending it. - - Args: - messages: The conversation messages to send - max_tokens: Maximum number of tokens to generate - system_prompt: Optional system prompt - include_context: Optional context inclusion setting - temperature: Optional sampling temperature - stop_sequences: Optional stop sequences - metadata: Optional metadata to pass through to the LLM provider - model_preferences: Optional model selection preferences - tools: Optional list of tools the LLM can use during sampling - tool_choice: Optional control over tool usage behavior - related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata - task: If provided, makes this a task-augmented request - - Returns: - A JSONRPCRequest ready to be sent or queued - """ - params = types.CreateMessageRequestParams( - messages=messages, - systemPrompt=system_prompt, - includeContext=include_context, - temperature=temperature, - maxTokens=max_tokens, - stopSequences=stop_sequences, - metadata=metadata, - modelPreferences=model_preferences, - tools=tools, - toolChoice=tool_choice, - task=task, - ) - params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) - - # Add related-task metadata if associated with a parent task - if related_task_id is not None: - # Defensive: model_dump() never includes _meta, but guard against future changes - if "_meta" not in params_data: # pragma: no cover - params_data["_meta"] = {} - params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( - taskId=related_task_id - ).model_dump(by_alias=True) - - request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id - if related_task_id is None: - self._request_id += 1 - - return types.JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - method="sampling/createMessage", - params=params_data, - ) - - async def send_message(self, message: SessionMessage) -> None: - """Send a raw session message. - - This is primarily used by TaskResultHandler to deliver queued messages - (elicitation/sampling requests) to the client during task execution. - - WARNING: This is a low-level experimental method that may change without - notice. Prefer using higher-level methods like send_notification() or - send_request() for normal operations. - - Args: - message: The session message to send - """ - await self._write_stream.send(message) - - async def _handle_incoming(self, req: ServerRequestResponder) -> None: - await self._incoming_message_stream_writer.send(req) - - @property - def incoming_messages(self) -> MemoryObjectReceiveStream[ServerRequestResponder]: - return self._incoming_message_stream_reader diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/sse.py b/src/agent_workflow/mcp/sdk/src/mcp/server/sse.py deleted file mode 100644 index 489785c..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/sse.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -SSE Server Transport Module - -This module implements a Server-Sent Events (SSE) transport layer for MCP servers. - -Example usage: -``` - # Create an SSE transport at an endpoint - sse = SseServerTransport("/messages/") - - # Create Starlette routes for SSE and message handling - routes = [ - Route("/sse", endpoint=handle_sse, methods=["GET"]), - Mount("/messages/", app=sse.handle_post_message), - ] - - # Define handler functions - async def handle_sse(request): - async with sse.connect_sse( - request.scope, request.receive, request._send - ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) - # Return empty response to avoid NoneType error - return Response() - - # Create and run Starlette app - starlette_app = Starlette(routes=routes) - uvicorn.run(starlette_app, host="127.0.0.1", port=port) -``` - -Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType' -object is not callable" error when client disconnects. The example above returns -an empty Response() after the SSE connection ends to fix this. - -See SseServerTransport class documentation for more details. -""" - -import logging -from contextlib import asynccontextmanager -from typing import Any -from urllib.parse import quote -from uuid import UUID, uuid4 - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import ValidationError -from sse_starlette import EventSourceResponse -from starlette.requests import Request -from starlette.responses import Response -from starlette.types import Receive, Scope, Send - -import mcp.types as types -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context -from mcp.server.transport_security import ( - TransportSecurityMiddleware, - TransportSecuritySettings, -) -from mcp.shared.message import ServerMessageMetadata, SessionMessage - -logger = logging.getLogger(__name__) - - -class SseServerTransport: - """ - SSE server transport for MCP. This class provides _two_ ASGI applications, - suitable to be used with a framework like Starlette and a server like Hypercorn: - - 1. connect_sse() is an ASGI application which receives incoming GET requests, - and sets up a new SSE stream to send server messages to the client. - 2. handle_post_message() is an ASGI application which receives incoming POST - requests, which should contain client messages that link to a - previously-established SSE session. - """ - - _endpoint: str - _read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]] - # Identity of the credential that created each session; requests for a - # session must present the same credential. - _session_owners: dict[UUID, AuthorizationContext] - _security: TransportSecurityMiddleware - - def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None: - """ - Creates a new SSE server transport, which will direct the client to POST - messages to the relative path given. - - Args: - endpoint: A relative path where messages should be posted - (e.g., "/messages/"). - security_settings: Optional security settings for DNS rebinding protection. - - Note: - We use relative paths instead of full URLs for several reasons: - 1. Security: Prevents cross-origin requests by ensuring clients only connect - to the same origin they established the SSE connection with - 2. Flexibility: The server can be mounted at any path without needing to - know its full URL - 3. Portability: The same endpoint configuration works across different - environments (development, staging, production) - - Raises: - ValueError: If the endpoint is a full URL instead of a relative path - """ - - super().__init__() - - # Validate that endpoint is a relative path and not a full URL - if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint: - raise ValueError( - f"Given endpoint: {endpoint} is not a relative path (e.g., '/messages/'), " - "expecting a relative path (e.g., '/messages/')." - ) - - # Ensure endpoint starts with a forward slash - if not endpoint.startswith("/"): - endpoint = "/" + endpoint - - self._endpoint = endpoint - self._read_stream_writers = {} - self._session_owners = {} - self._security = TransportSecurityMiddleware(security_settings) - logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}") - - @asynccontextmanager - async def connect_sse(self, scope: Scope, receive: Receive, send: Send): # pragma: no cover - if scope["type"] != "http": - logger.error("connect_sse received non-HTTP request") - raise ValueError("connect_sse can only handle HTTP requests") - - # Validate request headers for DNS rebinding protection - request = Request(scope, receive) - error_response = await self._security.validate_request(request, is_post=False) - if error_response: - await error_response(scope, receive, send) - raise ValueError("Request validation failed") - - logger.debug("Setting up SSE connection") - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - session_id = uuid4() - user = scope.get("user") - if isinstance(user, AuthenticatedUser): - self._session_owners[session_id] = authorization_context(user) - self._read_stream_writers[session_id] = read_stream_writer - logger.debug(f"Created new session with ID: {session_id}") - - # Determine the full path for the message endpoint to be sent to the client. - # scope['root_path'] is the prefix where the current Starlette app - # instance is mounted. - # e.g., "" if top-level, or "/api_prefix" if mounted under "/api_prefix". - root_path = scope.get("root_path", "") - - # self._endpoint is the path *within* this app, e.g., "/messages". - # Concatenating them gives the full absolute path from the server root. - # e.g., "" + "/messages" -> "/messages" - # e.g., "/api_prefix" + "/messages" -> "/api_prefix/messages" - full_message_path_for_client = root_path.rstrip("/") + self._endpoint - - # This is the URI (path + query) the client will use to POST messages. - client_post_uri_data = f"{quote(full_message_path_for_client)}?session_id={session_id.hex}" - - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, Any]](0) - - async def sse_writer(): - logger.debug("Starting SSE writer") - async with sse_stream_writer, write_stream_reader: - await sse_stream_writer.send({"event": "endpoint", "data": client_post_uri_data}) - logger.debug(f"Sent endpoint event: {client_post_uri_data}") - - async for session_message in write_stream_reader: - logger.debug(f"Sending message via SSE: {session_message}") - await sse_stream_writer.send( - { - "event": "message", - "data": session_message.message.model_dump_json(by_alias=True, exclude_none=True), - } - ) - - try: - async with anyio.create_task_group() as tg: - - async def response_wrapper(scope: Scope, receive: Receive, send: Send): - """ - The EventSourceResponse returning signals a client close / disconnect. - In this case we close our side of the streams to signal the client that - the connection has been closed. - """ - await EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer)( - scope, receive, send - ) - await read_stream_writer.aclose() - await write_stream_reader.aclose() - await sse_stream_reader.aclose() - logging.debug(f"Client session disconnected {session_id}") - - logger.debug("Starting SSE response task") - tg.start_soon(response_wrapper, scope, receive, send) - - logger.debug("Yielding read and write streams") - yield (read_stream, write_stream) - finally: - # The connection is gone: stop routing messages to this session - # and drop its entries so they do not accumulate for the lifetime - # of the transport. - self._read_stream_writers.pop(session_id, None) - self._session_owners.pop(session_id, None) - - async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover - logger.debug("Handling POST message") - request = Request(scope, receive) - - # Validate request headers for DNS rebinding protection - error_response = await self._security.validate_request(request, is_post=True) - if error_response: - return await error_response(scope, receive, send) - - session_id_param = request.query_params.get("session_id") - if session_id_param is None: - logger.warning("Received request without session_id") - response = Response("session_id is required", status_code=400) - return await response(scope, receive, send) - - try: - session_id = UUID(hex=session_id_param) - logger.debug(f"Parsed session ID: {session_id}") - except ValueError: - logger.warning(f"Received invalid session ID: {session_id_param}") - response = Response("Invalid session ID", status_code=400) - return await response(scope, receive, send) - - writer = self._read_stream_writers.get(session_id) - if not writer: - logger.warning(f"Could not find session for ID: {session_id}") - response = Response("Could not find session", status_code=404) - return await response(scope, receive, send) - - user = scope.get("user") - requestor = authorization_context(user) if isinstance(user, AuthenticatedUser) else None - if requestor != self._session_owners.get(session_id): - # A session can only be used with the credential that created it. - # Respond exactly as if the session did not exist. - logger.warning("Rejecting message for session %s: credential does not match", session_id) - response = Response("Could not find session", status_code=404) - return await response(scope, receive, send) - - body = await request.body() - logger.debug(f"Received JSON: {body}") - - try: - message = types.JSONRPCMessage.model_validate_json(body) - logger.debug(f"Validated client message: {message}") - except ValidationError as err: - logger.exception("Failed to parse message") - response = Response("Could not parse message", status_code=400) - await response(scope, receive, send) - await writer.send(err) - return - - # Pass the ASGI scope for framework-agnostic access to request data - metadata = ServerMessageMetadata(request_context=request) - session_message = SessionMessage(message, metadata=metadata) - logger.debug(f"Sending session message to writer: {session_message}") - response = Response("Accepted", status_code=202) - await response(scope, receive, send) - await writer.send(session_message) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/stdio.py b/src/agent_workflow/mcp/sdk/src/mcp/server/stdio.py deleted file mode 100644 index ec029f1..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/stdio.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Stdio Server Transport Module - -This module provides functionality for creating an stdio-based transport layer -that can be used to communicate with an MCP client through standard input/output -streams. - -Example usage: -``` - async def run_server(): - async with stdio_server() as (read_stream, write_stream): - # read_stream contains incoming JSONRPCMessages from stdin - # write_stream allows sending JSONRPCMessages to stdout - server = await create_my_server() - await server.run(read_stream, write_stream, init_options) - - anyio.run(run_server) -``` -""" - -import sys -from contextlib import asynccontextmanager -from io import TextIOWrapper - -import anyio -import anyio.lowlevel -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream - -import mcp.types as types -from mcp.shared.message import SessionMessage - - -@asynccontextmanager -async def stdio_server( - stdin: anyio.AsyncFile[str] | None = None, - stdout: anyio.AsyncFile[str] | None = None, -): - """ - Server transport for stdio: this communicates with an MCP client by reading - from the current process' stdin and writing to stdout. - """ - # Purposely not using context managers for these, as we don't want to close - # standard process handles. Encoding of stdin/stdout as text streams on - # python is platform-dependent (Windows is particularly problematic), so we - # re-wrap the underlying binary stream to ensure UTF-8. - if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) - if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) - - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - async def stdin_reader(): - try: - async with read_stream_writer: - async for line in stdin: - try: - message = types.JSONRPCMessage.model_validate_json(line) - except Exception as exc: - await read_stream_writer.send(exc) - continue - - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: no cover - await anyio.lowlevel.checkpoint() - - async def stdout_writer(): - try: - async with write_stream_reader: - async for session_message in write_stream_reader: - json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) - await stdout.write(json + "\n") - await stdout.flush() - except anyio.ClosedResourceError: # pragma: no cover - await anyio.lowlevel.checkpoint() - - async with anyio.create_task_group() as tg: - tg.start_soon(stdin_reader) - tg.start_soon(stdout_writer) - yield read_stream, write_stream diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http.py b/src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http.py deleted file mode 100644 index 8e8d902..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http.py +++ /dev/null @@ -1,1086 +0,0 @@ -""" -StreamableHTTP Server Transport Module - -This module implements an HTTP transport layer with Streamable HTTP. - -The transport handles bidirectional communication using HTTP requests and -responses, with streaming support for long-running operations. -""" - -import json -import logging -import re -from abc import ABC, abstractmethod -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import asynccontextmanager -from dataclasses import dataclass -from functools import partial -from http import HTTPStatus -from typing import Any, Final - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import ValidationError -from sse_starlette import EventSourceResponse -from starlette.requests import Request -from starlette.responses import Response -from starlette.types import Receive, Scope, Send - -from mcp.server.transport_security import ( - TransportSecurityMiddleware, - TransportSecuritySettings, -) -from mcp.shared.message import ServerMessageMetadata, SessionMessage -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS -from mcp.types import ( - DEFAULT_NEGOTIATED_VERSION, - INTERNAL_ERROR, - INVALID_PARAMS, - INVALID_REQUEST, - PARSE_ERROR, - ErrorData, - JSONRPCError, - JSONRPCMessage, - JSONRPCRequest, - JSONRPCResponse, - RequestId, -) - -logger = logging.getLogger(__name__) - - -# Header names -MCP_SESSION_ID_HEADER = "mcp-session-id" -MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" -LAST_EVENT_ID_HEADER = "last-event-id" - -# Content types -CONTENT_TYPE_JSON = "application/json" -CONTENT_TYPE_SSE = "text/event-stream" - -# Special key for the standalone GET stream -GET_STREAM_KEY = "_GET_stream" - -# Buffer for the per-request `_request_streams` so the serial `message_router` -# can deposit a response and move on instead of head-of-line blocking the -# whole session on a lazily-started `sse_writer`. See #1764. -REQUEST_STREAM_BUFFER_SIZE: Final = 16 - -# Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E) -# Pattern ensures entire string contains only valid characters by using ^ and $ anchors -SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$") - -# Type aliases -StreamId = str -EventId = str -# An SSE event-dict as accepted by sse-starlette (`event`, `data`, `id`, `retry`). -SSEEvent = dict[str, Any] - - -@dataclass -class EventMessage: - """ - A JSONRPCMessage with an optional event ID for stream resumability. - """ - - message: JSONRPCMessage - event_id: str | None = None - - -EventCallback = Callable[[EventMessage], Awaitable[None]] - - -class EventStore(ABC): - """ - Interface for resumability support via event storage. - """ - - @abstractmethod - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """ - Stores an event for later retrieval. - - Args: - stream_id: ID of the stream the event belongs to - message: The JSON-RPC message to store, or None for priming events - - Returns: - The generated event ID for the stored event - """ - pass # pragma: no cover - - @abstractmethod - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """ - Replays events that occurred after the specified event ID. - - Args: - last_event_id: The ID of the last event the client received - send_callback: A callback function to send events to the client - - Returns: - The stream ID of the replayed events - """ - pass # pragma: no cover - - -class StreamableHTTPServerTransport: - """ - HTTP server transport with event streaming support for MCP. - - Handles JSON-RPC messages in HTTP POST requests with SSE streaming. - Supports optional JSON responses and session management. - """ - - # Server notification streams for POST requests as well as standalone SSE stream - _read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] | None = None - _read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None - _write_stream: MemoryObjectSendStream[SessionMessage] | None = None - _write_stream_reader: MemoryObjectReceiveStream[SessionMessage] | None = None - _security: TransportSecurityMiddleware - - def __init__( - self, - mcp_session_id: str | None, - is_json_response_enabled: bool = False, - event_store: EventStore | None = None, - security_settings: TransportSecuritySettings | None = None, - retry_interval: int | None = None, - ) -> None: - """ - Initialize a new StreamableHTTP server transport. - - Args: - mcp_session_id: Optional session identifier for this connection. - Must contain only visible ASCII characters (0x21-0x7E). - is_json_response_enabled: If True, return JSON responses for requests - instead of SSE streams. Default is False. - event_store: Event store for resumability support. If provided, - resumability will be enabled, allowing clients to - reconnect and resume messages. - security_settings: Optional security settings for DNS rebinding protection. - retry_interval: Retry interval in milliseconds to suggest to clients in SSE - retry field. When set, the server will send a retry field in - SSE priming events to control client reconnection timing for - polling behavior. Only used when event_store is provided. - - Raises: - ValueError: If the session ID contains invalid characters. - """ - if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id): - raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)") - - self.mcp_session_id = mcp_session_id - self.is_json_response_enabled = is_json_response_enabled - self._event_store = event_store - self._security = TransportSecurityMiddleware(security_settings) - self._retry_interval = retry_interval - self._request_streams: dict[ - RequestId, - tuple[ - MemoryObjectSendStream[EventMessage], - MemoryObjectReceiveStream[EventMessage], - ], - ] = {} - self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} - self._terminated = False - # Idle timeout cancel scope; managed by the session manager. - self.idle_scope: anyio.CancelScope | None = None - - @property - def is_terminated(self) -> bool: - """Check if this transport has been explicitly terminated.""" - return self._terminated - - def close_sse_stream(self, request_id: RequestId) -> None: # pragma: no cover - """Close SSE connection for a specific request without terminating the stream. - - This method closes the HTTP connection for the specified request, triggering - client reconnection. Events continue to be stored in the event store and will - be replayed when the client reconnects with Last-Event-ID. - - Use this to implement polling behavior during long-running operations - - client will reconnect after the retry interval specified in the priming event. - - Args: - request_id: The request ID whose SSE stream should be closed. - - Note: - This is a no-op if there is no active stream for the request ID. - Requires event_store to be configured for events to be stored during - the disconnect. - """ - writer = self._sse_stream_writers.pop(request_id, None) - if writer: - writer.close() - - # Also close and remove request streams - if request_id in self._request_streams: - send_stream, receive_stream = self._request_streams.pop(request_id) - send_stream.close() - receive_stream.close() - - def close_standalone_sse_stream(self) -> None: # pragma: no cover - """Close the standalone GET SSE stream, triggering client reconnection. - - This method closes the HTTP connection for the standalone GET stream used - for unsolicited server-to-client notifications. The client SHOULD reconnect - with Last-Event-ID to resume receiving notifications. - - Use this to implement polling behavior for the notification stream - - client will reconnect after the retry interval specified in the priming event. - - Note: - This is a no-op if there is no active standalone SSE stream. - Requires event_store to be configured for events to be stored during - the disconnect. - Currently, client reconnection for standalone GET streams is NOT - implemented - this is a known gap (see test_standalone_get_stream_reconnection). - """ - self.close_sse_stream(GET_STREAM_KEY) - - def _create_session_message( # pragma: no cover - self, - message: JSONRPCMessage, - request: Request, - request_id: RequestId, - protocol_version: str, - ) -> SessionMessage: - """Create a session message with metadata including close_sse_stream callback. - - The close_sse_stream callbacks are only provided when the client supports - resumability (protocol version >= 2025-11-25). Old clients can't resume if - the stream is closed early because they didn't receive a priming event. - """ - # Only provide close callbacks when client supports resumability - if self._event_store and protocol_version >= "2025-11-25": - - async def close_stream_callback() -> None: - self.close_sse_stream(request_id) - - async def close_standalone_stream_callback() -> None: - self.close_standalone_sse_stream() - - metadata = ServerMessageMetadata( - request_context=request, - close_sse_stream=close_stream_callback, - close_standalone_sse_stream=close_standalone_stream_callback, - ) - else: - metadata = ServerMessageMetadata(request_context=request) - - return SessionMessage(message, metadata=metadata) - - async def _mint_priming_event(self, stream_id: StreamId, protocol_version: str) -> SSEEvent | None: - """Store the priming cursor for `stream_id` and return its SSE wire form. - - Called before the request is dispatched so the priming row precedes - anything `message_router` can store for this stream. Returns `None` - when no event store is configured or the client predates 2025-11-25 - (older clients cannot parse the empty-data event). - """ - if not self._event_store: - return None - if protocol_version < "2025-11-25": - return None - priming_event_id = await self._event_store.store_event(stream_id, None) - priming_event: SSEEvent = {"id": priming_event_id, "data": ""} - if self._retry_interval is not None: - priming_event["retry"] = self._retry_interval - return priming_event - - async def _run_sse_writer( # pragma: no cover - self, - request_id: RequestId, - sse_stream_writer: MemoryObjectSendStream[SSEEvent], - request_stream_reader: MemoryObjectReceiveStream[EventMessage], - priming_event: SSEEvent | None, - ) -> None: - """Forward `_request_streams[request_id]` onto the SSE wire for one POST.""" - try: - async with sse_stream_writer, request_stream_reader: - if priming_event is not None: - await sse_stream_writer.send(priming_event) - async for event_message in request_stream_reader: - await sse_stream_writer.send(self._create_event_data(event_message)) - if isinstance(event_message.message.root, JSONRPCResponse | JSONRPCError): - break - except anyio.ClosedResourceError: - logger.debug("SSE stream closed by close_sse_stream()") - except Exception: - logger.exception("Error in SSE writer") - finally: - logger.debug("Closing SSE writer") - self._sse_stream_writers.pop(request_id, None) - await self._clean_up_memory_streams(request_id) - - def _create_error_response( - self, - error_message: str, - status_code: HTTPStatus, - error_code: int = INVALID_REQUEST, - headers: dict[str, str] | None = None, - ) -> Response: - """Create an error response with a simple string message.""" - response_headers = {"Content-Type": CONTENT_TYPE_JSON} - if headers: # pragma: no cover - response_headers.update(headers) - - if self.mcp_session_id: - response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id - - # Return a properly formatted JSON error response - error_response = JSONRPCError( - jsonrpc="2.0", - id="server-error", # We don't have a request ID for general errors - error=ErrorData( - code=error_code, - message=error_message, - ), - ) - - return Response( - error_response.model_dump_json(by_alias=True, exclude_none=True), - status_code=status_code, - headers=response_headers, - ) - - def _create_json_response( # pragma: no cover - self, - response_message: JSONRPCMessage | None, - status_code: HTTPStatus = HTTPStatus.OK, - headers: dict[str, str] | None = None, - ) -> Response: - """Create a JSON response from a JSONRPCMessage""" - response_headers = {"Content-Type": CONTENT_TYPE_JSON} - if headers: - response_headers.update(headers) - - if self.mcp_session_id: - response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id - - return Response( - response_message.model_dump_json(by_alias=True, exclude_none=True) if response_message else None, - status_code=status_code, - headers=response_headers, - ) - - def _get_session_id(self, request: Request) -> str | None: # pragma: no cover - """Extract the session ID from request headers.""" - return request.headers.get(MCP_SESSION_ID_HEADER) - - def _create_event_data(self, event_message: EventMessage) -> SSEEvent: # pragma: no cover - """Create event data dictionary from an EventMessage.""" - event_data = { - "event": "message", - "data": event_message.message.model_dump_json(by_alias=True, exclude_none=True), - } - - # If an event ID was provided, include it - if event_message.event_id: - event_data["id"] = event_message.event_id - - return event_data - - async def _clean_up_memory_streams(self, request_id: RequestId) -> None: # pragma: no cover - """Clean up memory streams for a given request ID.""" - if request_id in self._request_streams: - try: - # Close the request stream - await self._request_streams[request_id][0].aclose() - await self._request_streams[request_id][1].aclose() - except Exception: - # During cleanup, we catch all exceptions since streams might be in various states - logger.debug("Error closing memory streams - may already be closed") - finally: - # Remove the request stream from the mapping - self._request_streams.pop(request_id, None) - - async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: - """Application entry point that handles all HTTP requests""" - request = Request(scope, receive) - - # Validate request headers for DNS rebinding protection - is_post = request.method == "POST" - error_response = await self._security.validate_request(request, is_post=is_post) - if error_response: # pragma: no cover - await error_response(scope, receive, send) - return - - if self._terminated: # pragma: no cover - # If the session has been terminated, return 404 Not Found - response = self._create_error_response( - "Not Found: Session has been terminated", - HTTPStatus.NOT_FOUND, - ) - await response(scope, receive, send) - return - - if request.method == "POST": - await self._handle_post_request(scope, request, receive, send) - elif request.method == "GET": # pragma: no cover - await self._handle_get_request(request, send) - elif request.method == "DELETE": # pragma: no cover - await self._handle_delete_request(request, send) - else: # pragma: no cover - await self._handle_unsupported_request(request, send) - - def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: - """Check if the request accepts the required media types.""" - accept_header = request.headers.get("accept", "") - accept_types = [media_type.strip() for media_type in accept_header.split(",")] - - has_json = any(media_type.startswith(CONTENT_TYPE_JSON) for media_type in accept_types) - has_sse = any(media_type.startswith(CONTENT_TYPE_SSE) for media_type in accept_types) - - return has_json, has_sse - - def _check_content_type(self, request: Request) -> bool: - """Check if the request has the correct Content-Type.""" - content_type = request.headers.get("content-type", "") - content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")] - - return any(part == CONTENT_TYPE_JSON for part in content_type_parts) - - async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool: # pragma: no cover - """Validate Accept header based on response mode. Returns True if valid.""" - has_json, has_sse = self._check_accept_headers(request) - if self.is_json_response_enabled: - # For JSON-only responses, only require application/json - if not has_json: - response = self._create_error_response( - "Not Acceptable: Client must accept application/json", - HTTPStatus.NOT_ACCEPTABLE, - ) - await response(scope, request.receive, send) - return False - # For SSE responses, require both content types - elif not (has_json and has_sse): - response = self._create_error_response( - "Not Acceptable: Client must accept both application/json and text/event-stream", - HTTPStatus.NOT_ACCEPTABLE, - ) - await response(scope, request.receive, send) - return False - return True - - async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None: - """Handle POST requests containing JSON-RPC messages.""" - writer = self._read_stream_writer - if writer is None: # pragma: no cover - raise ValueError("No read stream writer available. Ensure connect() is called first.") - try: - # Validate Accept header - if not await self._validate_accept_header(request, scope, send): - return - - # Validate Content-Type - if not self._check_content_type(request): # pragma: no cover - response = self._create_error_response( - "Unsupported Media Type: Content-Type must be application/json", - HTTPStatus.UNSUPPORTED_MEDIA_TYPE, - ) - await response(scope, receive, send) - return - - # Parse the body - only read it once - body = await request.body() - - try: - raw_message = json.loads(body) - except json.JSONDecodeError as e: - response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR) - await response(scope, receive, send) - return - - try: # pragma: no cover - message = JSONRPCMessage.model_validate(raw_message) - except ValidationError as e: # pragma: no cover - response = self._create_error_response( - f"Validation error: {str(e)}", - HTTPStatus.BAD_REQUEST, - INVALID_PARAMS, - ) - await response(scope, receive, send) - return - - # Check if this is an initialization request - is_initialization_request = ( - isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" - ) # pragma: no cover - - if is_initialization_request: # pragma: no cover - # Check if the server already has an established session - if self.mcp_session_id: - # Check if request has a session ID - request_session_id = self._get_session_id(request) - - # If request has a session ID but doesn't match, return 404 - if request_session_id and request_session_id != self.mcp_session_id: - response = self._create_error_response( - "Not Found: Invalid or expired session ID", - HTTPStatus.NOT_FOUND, - ) - await response(scope, receive, send) - return - elif not await self._validate_request_headers(request, send): # pragma: no cover - return - - # For notifications and responses only, return 202 Accepted - if not isinstance(message.root, JSONRPCRequest): # pragma: no cover - # Create response object and send it - response = self._create_json_response( - None, - HTTPStatus.ACCEPTED, - ) - await response(scope, receive, send) - - # Process the message after sending the response - metadata = ServerMessageMetadata(request_context=request) - session_message = SessionMessage(message, metadata=metadata) - await writer.send(session_message) - - return - - # Extract protocol version for priming event decision. - # For initialize requests, get from request params. - # For other requests, get from header (already validated). - protocol_version = ( - str(message.root.params.get("protocolVersion", DEFAULT_NEGOTIATED_VERSION)) - if is_initialization_request and message.root.params - else request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) - ) - - request_id = str(message.root.id) - - if self.is_json_response_enabled: # pragma: no cover - self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage]( - REQUEST_STREAM_BUFFER_SIZE - ) - request_stream_reader = self._request_streams[request_id][1] - # Process the message - metadata = ServerMessageMetadata(request_context=request) - session_message = SessionMessage(message, metadata=metadata) - await writer.send(session_message) - try: - # Process messages from the request-specific stream - # We need to collect all messages until we get a response - response_message = None - - # Use similar approach to SSE writer for consistency - async for event_message in request_stream_reader: - # If it's a response, this is what we're waiting for - if isinstance(event_message.message.root, JSONRPCResponse | JSONRPCError): - response_message = event_message.message - break - # For notifications and request, keep waiting - else: - logger.debug(f"received: {event_message.message.root.method}") - - # At this point we should have a response - if response_message: - # Create JSON response - response = self._create_json_response(response_message) - await response(scope, receive, send) - else: - # This shouldn't happen in normal operation - logger.error("No response message received before stream closed") - response = self._create_error_response( - "Error processing request: No response received", - HTTPStatus.INTERNAL_SERVER_ERROR, - ) - await response(scope, receive, send) - except Exception: - logger.exception("Error processing JSON response") - response = self._create_error_response( - "Error processing request", - HTTPStatus.INTERNAL_SERVER_ERROR, - INTERNAL_ERROR, - ) - await response(scope, receive, send) - finally: - await self._clean_up_memory_streams(request_id) - else: # pragma: no cover - # Mint the priming event before any per-request state exists: - # `EventStore.store_event` is user code and may raise, in which - # case the outer handler returns a 500 with nothing to clean up. - # Still strictly precedes dispatch, so storage order == wire order. - priming_event = await self._mint_priming_event(request_id, protocol_version) - - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) - self._sse_stream_writers[request_id] = sse_stream_writer - self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage]( - REQUEST_STREAM_BUFFER_SIZE - ) - request_stream_reader = self._request_streams[request_id][1] - - headers = { - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "Content-Type": CONTENT_TYPE_SSE, - **({MCP_SESSION_ID_HEADER: self.mcp_session_id} if self.mcp_session_id else {}), - } - response = EventSourceResponse( - content=sse_stream_reader, - data_sender_callable=partial( - self._run_sse_writer, request_id, sse_stream_writer, request_stream_reader, priming_event - ), - headers=headers, - ) - - # Start the SSE response (this will send headers immediately) - try: - # First send the response to establish the SSE connection - async with anyio.create_task_group() as tg: - tg.start_soon(response, scope, receive, send) - # Then send the message to be processed by the server - session_message = self._create_session_message(message, request, request_id, protocol_version) - await writer.send(session_message) - except Exception: - logger.exception("SSE response error") - await sse_stream_writer.aclose() - await sse_stream_reader.aclose() - await self._clean_up_memory_streams(request_id) - - except Exception as err: - logger.exception("Error handling POST request") - response = self._create_error_response( - "Error handling POST request", - HTTPStatus.INTERNAL_SERVER_ERROR, - INTERNAL_ERROR, - ) - await response(scope, receive, send) - await writer.send(Exception(err)) - return - - async def _handle_get_request(self, request: Request, send: Send) -> None: # pragma: no cover - """ - Handle GET request to establish SSE. - - This allows the server to communicate to the client without the client - first sending data via HTTP POST. The server can send JSON-RPC requests - and notifications on this stream. - """ - writer = self._read_stream_writer - if writer is None: - raise ValueError("No read stream writer available. Ensure connect() is called first.") - - # Validate Accept header - must include text/event-stream - _, has_sse = self._check_accept_headers(request) - - if not has_sse: - response = self._create_error_response( - "Not Acceptable: Client must accept text/event-stream", - HTTPStatus.NOT_ACCEPTABLE, - ) - await response(request.scope, request.receive, send) - return - - if not await self._validate_request_headers(request, send): - return - - # Handle resumability: check for Last-Event-ID header - if last_event_id := request.headers.get(LAST_EVENT_ID_HEADER): - await self._replay_events(last_event_id, request, send) - return - - headers = { - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "Content-Type": CONTENT_TYPE_SSE, - } - - if self.mcp_session_id: - headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id - - # Check if we already have an active GET stream - if GET_STREAM_KEY in self._request_streams: - response = self._create_error_response( - "Conflict: Only one SSE stream is allowed per session", - HTTPStatus.CONFLICT, - ) - await response(request.scope, request.receive, send) - return - - # Create SSE stream - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) - - async def standalone_sse_writer(): - try: - # Create a standalone message stream for server-initiated messages - - self._request_streams[GET_STREAM_KEY] = anyio.create_memory_object_stream[EventMessage]( - REQUEST_STREAM_BUFFER_SIZE - ) - standalone_stream_reader = self._request_streams[GET_STREAM_KEY][1] - - async with sse_stream_writer, standalone_stream_reader: - # Process messages from the standalone stream - async for event_message in standalone_stream_reader: - # For the standalone stream, we handle: - # - JSONRPCNotification (server sends notifications to client) - # - JSONRPCRequest (server sends requests to client) - # We should NOT receive JSONRPCResponse - - # Send the message via SSE - event_data = self._create_event_data(event_message) - await sse_stream_writer.send(event_data) - except Exception: - logger.exception("Error in standalone SSE writer") - finally: - logger.debug("Closing standalone SSE writer") - await self._clean_up_memory_streams(GET_STREAM_KEY) - - # Create and start EventSourceResponse - response = EventSourceResponse( - content=sse_stream_reader, - data_sender_callable=standalone_sse_writer, - headers=headers, - ) - - try: - # This will send headers immediately and establish the SSE connection - await response(request.scope, request.receive, send) - except Exception: - logger.exception("Error in standalone SSE response") - await sse_stream_writer.aclose() - await sse_stream_reader.aclose() - await self._clean_up_memory_streams(GET_STREAM_KEY) - - async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover - """Handle DELETE requests for explicit session termination.""" - # Validate session ID - if not self.mcp_session_id: - # If no session ID set, return Method Not Allowed - response = self._create_error_response( - "Method Not Allowed: Session termination not supported", - HTTPStatus.METHOD_NOT_ALLOWED, - ) - await response(request.scope, request.receive, send) - return - - if not await self._validate_request_headers(request, send): - return - - await self.terminate() - - response = self._create_json_response( - None, - HTTPStatus.OK, - ) - await response(request.scope, request.receive, send) - - async def terminate(self) -> None: - """Terminate the current session, closing all streams. - - Once terminated, all requests with this session ID will receive 404 Not Found. - Calling this method multiple times is safe (idempotent). - """ - - if self._terminated: # pragma: no cover - return - - self._terminated = True - logger.info(f"Terminating session: {self.mcp_session_id}") - - # We need a copy of the keys to avoid modification during iteration - request_stream_keys = list(self._request_streams.keys()) - - # Close all request streams asynchronously - for key in request_stream_keys: # pragma: no cover - await self._clean_up_memory_streams(key) - - # Clear the request streams dictionary immediately - self._request_streams.clear() - try: - if self._read_stream_writer is not None: # pragma: no branch - await self._read_stream_writer.aclose() - if self._read_stream is not None: # pragma: no branch - await self._read_stream.aclose() - if self._write_stream_reader is not None: # pragma: no branch - await self._write_stream_reader.aclose() - if self._write_stream is not None: # pragma: no branch - await self._write_stream.aclose() - except Exception as e: # pragma: no cover - # During cleanup, we catch all exceptions since streams might be in various states - logger.debug(f"Error closing streams: {e}") - - async def _handle_unsupported_request(self, request: Request, send: Send) -> None: # pragma: no cover - """Handle unsupported HTTP methods.""" - headers = { - "Content-Type": CONTENT_TYPE_JSON, - "Allow": "GET, POST, DELETE", - } - if self.mcp_session_id: - headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id - - response = self._create_error_response( - "Method Not Allowed", - HTTPStatus.METHOD_NOT_ALLOWED, - headers=headers, - ) - await response(request.scope, request.receive, send) - - async def _validate_request_headers(self, request: Request, send: Send) -> bool: # pragma: no cover - if not await self._validate_session(request, send): - return False - if not await self._validate_protocol_version(request, send): - return False - return True - - async def _validate_session(self, request: Request, send: Send) -> bool: # pragma: no cover - """Validate the session ID in the request.""" - if not self.mcp_session_id: - # If we're not using session IDs, return True - return True - - # Get the session ID from the request headers - request_session_id = self._get_session_id(request) - - # If no session ID provided but required, return error - if not request_session_id: - response = self._create_error_response( - "Bad Request: Missing session ID", - HTTPStatus.BAD_REQUEST, - ) - await response(request.scope, request.receive, send) - return False - - # If session ID doesn't match, return error - if request_session_id != self.mcp_session_id: - response = self._create_error_response( - "Not Found: Invalid or expired session ID", - HTTPStatus.NOT_FOUND, - ) - await response(request.scope, request.receive, send) - return False - - return True - - async def _validate_protocol_version(self, request: Request, send: Send) -> bool: # pragma: no cover - """Validate the protocol version header in the request.""" - # Get the protocol version from the request headers - protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER) - - # If no protocol version provided, assume default version - if protocol_version is None: - protocol_version = DEFAULT_NEGOTIATED_VERSION - - # Check if the protocol version is supported - if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: - supported_versions = ", ".join(SUPPORTED_PROTOCOL_VERSIONS) - response = self._create_error_response( - f"Bad Request: Unsupported protocol version: {protocol_version}. " - + f"Supported versions: {supported_versions}", - HTTPStatus.BAD_REQUEST, - ) - await response(request.scope, request.receive, send) - return False - - return True - - async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None: # pragma: no cover - """ - Replays events that would have been sent after the specified event ID. - Only used when resumability is enabled. - """ - event_store = self._event_store - if not event_store: - return - - try: - headers = { - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "Content-Type": CONTENT_TYPE_SSE, - } - - if self.mcp_session_id: - headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id - - # Get protocol version from header (already validated in _validate_protocol_version) - replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) - - # Create SSE stream for replay - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) - - async def replay_sender(): - try: - async with sse_stream_writer: - # Define an async callback for sending events - async def send_event(event_message: EventMessage) -> None: - event_data = self._create_event_data(event_message) - await sse_stream_writer.send(event_data) - - # Replay past events and get the stream ID - stream_id = await event_store.replay_events_after(last_event_id, send_event) - - # If stream ID not in mapping, create it - if stream_id and stream_id not in self._request_streams: - try: - # Register SSE writer so close_sse_stream() can close it - self._sse_stream_writers[stream_id] = sse_stream_writer - - # Prime the resumed connection so the client sees the stream - # is re-registered. The replay→live-tail ordering window here - # is pre-existing and tracked separately. - priming_event = await self._mint_priming_event(stream_id, replay_protocol_version) - if priming_event is not None: - await sse_stream_writer.send(priming_event) - - # Create new request streams for this connection - self._request_streams[stream_id] = anyio.create_memory_object_stream[EventMessage]( - REQUEST_STREAM_BUFFER_SIZE - ) - msg_reader = self._request_streams[stream_id][1] - - # Forward messages to SSE - async with msg_reader: - async for event_message in msg_reader: - event_data = self._create_event_data(event_message) - - await sse_stream_writer.send(event_data) - finally: - self._sse_stream_writers.pop(stream_id, None) - await self._clean_up_memory_streams(stream_id) - except anyio.ClosedResourceError: - # Expected when close_sse_stream() is called - logger.debug("Replay SSE stream closed by close_sse_stream()") - except Exception: - logger.exception("Error in replay sender") - - # Create and start EventSourceResponse - response = EventSourceResponse( - content=sse_stream_reader, - data_sender_callable=replay_sender, - headers=headers, - ) - - try: - await response(request.scope, request.receive, send) - except Exception: - logger.exception("Error in replay response") - finally: - await sse_stream_writer.aclose() - await sse_stream_reader.aclose() - - except Exception: - logger.exception("Error replaying events") - response = self._create_error_response( - "Error replaying events", - HTTPStatus.INTERNAL_SERVER_ERROR, - INTERNAL_ERROR, - ) - await response(request.scope, request.receive, send) - - @asynccontextmanager - async def connect( - self, - ) -> AsyncGenerator[ - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - ], - None, - ]: - """Context manager that provides read and write streams for a connection. - - Yields: - Tuple of (read_stream, write_stream) for bidirectional communication - """ - - # Create the memory streams for this connection - - read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0) - write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) - - # Store the streams - self._read_stream_writer = read_stream_writer - self._read_stream = read_stream - self._write_stream_reader = write_stream_reader - self._write_stream = write_stream - - # Start a task group for message routing - async with anyio.create_task_group() as tg: - # Create a message router that distributes messages to request streams - async def message_router(): # pragma: no cover - try: - async for session_message in write_stream_reader: - # Determine which request stream(s) should receive this message - message = session_message.message - target_request_id = None - # Check if this is a response - if isinstance(message.root, JSONRPCResponse | JSONRPCError): - response_id = str(message.root.id) - # If this response is for an existing request stream, - # send it there - target_request_id = response_id - # Extract related_request_id from meta if it exists - elif ( - session_message.metadata is not None - and isinstance( - session_message.metadata, - ServerMessageMetadata, - ) - and session_message.metadata.related_request_id is not None - ): - target_request_id = str(session_message.metadata.related_request_id) - - request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY - - # Store the event if we have an event store, - # regardless of whether a client is connected - # messages will be replayed on the re-connect - event_id = None - if self._event_store: - event_id = await self._event_store.store_event(request_stream_id, message) - logger.debug(f"Stored {event_id} from {request_stream_id}") - - if request_stream_id in self._request_streams: - try: - # Send both the message and the event ID - await self._request_streams[request_stream_id][0].send(EventMessage(message, event_id)) - except ( - anyio.BrokenResourceError, - anyio.ClosedResourceError, - ): - # Stream might be closed, remove from registry - self._request_streams.pop(request_stream_id, None) - else: - logger.debug( - f"""Request stream {request_stream_id} not found - for message. Still processing message as the client - might reconnect and replay.""" - ) - except anyio.ClosedResourceError: - if self._terminated: - logger.debug("Read stream closed by client") - else: - logger.exception("Unexpected closure of read stream in message router") - except Exception: - logger.exception("Error in message router") - - # Start the message router - tg.start_soon(message_router) - - try: - # Yield the streams for the caller to use - yield read_stream, write_stream - finally: - for stream_id in list(self._request_streams.keys()): # pragma: no cover - await self._clean_up_memory_streams(stream_id) - self._request_streams.clear() - - # Clean up the read and write streams - try: - await read_stream_writer.aclose() - await read_stream.aclose() - await write_stream_reader.aclose() - await write_stream.aclose() - except Exception as e: # pragma: no cover - # During cleanup, we catch all exceptions since streams might be in various states - logger.debug(f"Error closing streams: {e}") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http_manager.py b/src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http_manager.py deleted file mode 100644 index 1a1a857..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/streamable_http_manager.py +++ /dev/null @@ -1,343 +0,0 @@ -"""StreamableHTTP Session Manager for MCP servers.""" - -from __future__ import annotations - -import contextlib -import logging -from collections.abc import AsyncIterator -from typing import Any -from uuid import uuid4 - -import anyio -from anyio.abc import TaskStatus -from starlette.requests import Request -from starlette.responses import Response -from starlette.types import Receive, Scope, Send - -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context -from mcp.server.lowlevel.server import Server as MCPServer -from mcp.server.streamable_http import ( - MCP_SESSION_ID_HEADER, - EventStore, - StreamableHTTPServerTransport, -) -from mcp.server.transport_security import TransportSecuritySettings -from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError - -logger = logging.getLogger(__name__) - - -class StreamableHTTPSessionManager: - """ - Manages StreamableHTTP sessions with optional resumability via event store. - - This class abstracts away the complexity of session management, event storage, - and request handling for StreamableHTTP transports. It handles: - - 1. Session tracking for clients - 2. Resumability via an optional event store - 3. Connection management and lifecycle - 4. Request handling and transport setup - 5. Idle session cleanup via optional timeout - - Important: Only one StreamableHTTPSessionManager instance should be created - per application. The instance cannot be reused after its run() context has - completed. If you need to restart the manager, create a new instance. - - Args: - app: The MCP server instance - event_store: Optional event store for resumability support. If provided, enables resumable connections - where clients can reconnect and receive missed events. If None, sessions are still tracked but not - resumable. - json_response: Whether to use JSON responses instead of SSE streams - stateless: If True, creates a completely fresh transport for each request with no session tracking or - state persistence between requests. - security_settings: Optional transport security settings. - retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE - polling behavior. - session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that - receive no HTTP requests for this duration will be automatically terminated and removed. When - retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to - avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800 - (30 minutes) is recommended for most deployments. - """ - - def __init__( - self, - app: MCPServer[Any, Any], - event_store: EventStore | None = None, - json_response: bool = False, - stateless: bool = False, - security_settings: TransportSecuritySettings | None = None, - retry_interval: int | None = None, - session_idle_timeout: float | None = None, - ): - if session_idle_timeout is not None and session_idle_timeout <= 0: - raise ValueError("session_idle_timeout must be a positive number of seconds") - if stateless and session_idle_timeout is not None: - raise RuntimeError("session_idle_timeout is not supported in stateless mode") - - self.app = app - self.event_store = event_store - self.json_response = json_response - self.stateless = stateless - self.security_settings = security_settings - self.retry_interval = retry_interval - self.session_idle_timeout = session_idle_timeout - - # Session tracking (only used if not stateless) - self._session_creation_lock = anyio.Lock() - self._server_instances: dict[str, StreamableHTTPServerTransport] = {} - # Identity of the credential that created each session; requests for a - # session must present the same credential. - self._session_owners: dict[str, AuthorizationContext] = {} - - # The task group will be set during lifespan - self._task_group = None - # Thread-safe tracking of run() calls - self._run_lock = anyio.Lock() - self._has_started = False - - @contextlib.asynccontextmanager - async def run(self) -> AsyncIterator[None]: - """ - Run the session manager with proper lifecycle management. - - This creates and manages the task group for all session operations. - - Important: This method can only be called once per instance. The same - StreamableHTTPSessionManager instance cannot be reused after this - context manager exits. Create a new instance if you need to restart. - - Use this in the lifespan context manager of your Starlette app: - - @contextlib.asynccontextmanager - async def lifespan(app: Starlette) -> AsyncIterator[None]: - async with session_manager.run(): - yield - """ - # Thread-safe check to ensure run() is only called once - async with self._run_lock: - if self._has_started: - raise RuntimeError( - "StreamableHTTPSessionManager .run() can only be called " - "once per instance. Create a new instance if you need to run again." - ) - self._has_started = True - - async with anyio.create_task_group() as tg: - # Store the task group for later use - self._task_group = tg - logger.info("StreamableHTTP session manager started") - try: - yield # Let the application run - finally: - logger.info("StreamableHTTP session manager shutting down") - # Cancel task group to stop all spawned tasks - tg.cancel_scope.cancel() - self._task_group = None - # Clear any remaining server instances - self._server_instances.clear() - self._session_owners.clear() - - async def handle_request( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - """ - Process ASGI request with proper session handling and transport setup. - - Dispatches to the appropriate handler based on stateless mode. - - Args: - scope: ASGI scope - receive: ASGI receive function - send: ASGI send function - """ - if self._task_group is None: - raise RuntimeError("Task group is not initialized. Make sure to use run().") - - # Dispatch to the appropriate handler - if self.stateless: - await self._handle_stateless_request(scope, receive, send) - else: - await self._handle_stateful_request(scope, receive, send) - - async def _handle_stateless_request( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - """ - Process request in stateless mode - creating a new transport for each request. - - Args: - scope: ASGI scope - receive: ASGI receive function - send: ASGI send function - """ - logger.debug("Stateless mode: Creating new transport for this request") - # No session ID needed in stateless mode - http_transport = StreamableHTTPServerTransport( - mcp_session_id=None, # No session tracking in stateless mode - is_json_response_enabled=self.json_response, - event_store=None, # No event store in stateless mode - security_settings=self.security_settings, - ) - - # Start server in a new task - async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED): - async with http_transport.connect() as streams: - read_stream, write_stream = streams - task_status.started() - try: - await self.app.run( - read_stream, - write_stream, - self.app.create_initialization_options(), - stateless=True, - ) - except Exception: # pragma: no cover - logger.exception("Stateless session crashed") - - # Assert task group is not None for type checking - assert self._task_group is not None - # Start the server task - await self._task_group.start(run_stateless_server) - - # Handle the HTTP request and return the response - await http_transport.handle_request(scope, receive, send) - - # Terminate the transport after the request is handled - await http_transport.terminate() - - async def _handle_stateful_request( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - """ - Process request in stateful mode - maintaining session state between requests. - - Args: - scope: ASGI scope - receive: ASGI receive function - send: ASGI send function - """ - request = Request(scope, receive) - request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER) - - user = scope.get("user") - requestor = authorization_context(user) if isinstance(user, AuthenticatedUser) else None - - # Existing session case - if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: - transport = self._server_instances[request_mcp_session_id] - if requestor != self._session_owners.get(request_mcp_session_id): - # A session can only be used with the credential that created - # it. Respond exactly as if the session did not exist. - logger.warning( - "Rejecting request for session %s: credential does not match the one that created the session", - request_mcp_session_id[:64], - ) - body = JSONRPCError( - jsonrpc="2.0", id="server-error", error=ErrorData(code=INVALID_REQUEST, message="Session not found") - ) - response = Response( - body.model_dump_json(by_alias=True, exclude_none=True), - status_code=404, - media_type="application/json", - ) - await response(scope, receive, send) - return - logger.debug("Session already exists, handling request directly") - # Push back idle deadline on activity - if transport.idle_scope is not None and self.session_idle_timeout is not None: # pragma: no cover - transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout - await transport.handle_request(scope, receive, send) - return - - if request_mcp_session_id is None: - # New session case - logger.debug("Creating new transport") - async with self._session_creation_lock: - new_session_id = uuid4().hex - http_transport = StreamableHTTPServerTransport( - mcp_session_id=new_session_id, - is_json_response_enabled=self.json_response, - event_store=self.event_store, # May be None (no resumability) - security_settings=self.security_settings, - retry_interval=self.retry_interval, - ) - - assert http_transport.mcp_session_id is not None - if requestor is not None: - self._session_owners[http_transport.mcp_session_id] = requestor - self._server_instances[http_transport.mcp_session_id] = http_transport - logger.info(f"Created new transport with session ID: {new_session_id}") - - # Define the server runner - async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: - async with http_transport.connect() as streams: - read_stream, write_stream = streams - task_status.started() - try: - # Use a cancel scope for idle timeout — when the - # deadline passes the scope cancels app.run() and - # execution continues after the ``with`` block. - # Incoming requests push the deadline forward. - idle_scope = anyio.CancelScope() - if self.session_idle_timeout is not None: - idle_scope.deadline = anyio.current_time() + self.session_idle_timeout - http_transport.idle_scope = idle_scope - - with idle_scope: - await self.app.run( - read_stream, - write_stream, - self.app.create_initialization_options(), - stateless=False, - ) - - if idle_scope.cancelled_caught: - assert http_transport.mcp_session_id is not None - logger.info(f"Session {http_transport.mcp_session_id} idle timeout") - self._server_instances.pop(http_transport.mcp_session_id, None) - self._session_owners.pop(http_transport.mcp_session_id, None) - await http_transport.terminate() - except Exception: - logger.exception(f"Session {http_transport.mcp_session_id} crashed") - finally: - if ( # pragma: no branch - http_transport.mcp_session_id - and http_transport.mcp_session_id in self._server_instances - and not http_transport.is_terminated - ): - logger.info( - "Cleaning up crashed session " - f"{http_transport.mcp_session_id} from " - "active instances." - ) - del self._server_instances[http_transport.mcp_session_id] - self._session_owners.pop(http_transport.mcp_session_id, None) - - # Assert task group is not None for type checking - assert self._task_group is not None - # Start the server task - await self._task_group.start(run_server) - - # Handle the HTTP request and return the response - await http_transport.handle_request(scope, receive, send) - else: - # Unknown or expired session ID - return 404 per MCP spec - body = JSONRPCError( - jsonrpc="2.0", id="server-error", error=ErrorData(code=INVALID_REQUEST, message="Session not found") - ) - response = Response( - body.model_dump_json(by_alias=True, exclude_none=True), status_code=404, media_type="application/json" - ) - await response(scope, receive, send) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/transport_security.py b/src/agent_workflow/mcp/sdk/src/mcp/server/transport_security.py deleted file mode 100644 index 5022a1a..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/transport_security.py +++ /dev/null @@ -1,127 +0,0 @@ -"""DNS rebinding protection for MCP server transports.""" - -import logging - -from pydantic import BaseModel, Field -from starlette.requests import HTTPConnection -from starlette.responses import Response - -logger = logging.getLogger(__name__) - - -class TransportSecuritySettings(BaseModel): - """Settings for MCP transport security features. - - These settings help protect against DNS rebinding attacks by validating - incoming request headers. - """ - - enable_dns_rebinding_protection: bool = Field( - default=True, - description="Enable DNS rebinding protection (recommended for production)", - ) - - allowed_hosts: list[str] = Field( - default=[], - description="List of allowed Host header values. Only applies when " - + "enable_dns_rebinding_protection is True.", - ) - - allowed_origins: list[str] = Field( - default=[], - description="List of allowed Origin header values. Only applies when " - + "enable_dns_rebinding_protection is True.", - ) - - -class TransportSecurityMiddleware: - """Middleware to enforce DNS rebinding protection for MCP transport endpoints.""" - - def __init__(self, settings: TransportSecuritySettings | None = None): - # If not specified, disable DNS rebinding protection by default - # for backwards compatibility - self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False) - - def _validate_host(self, host: str | None) -> bool: # pragma: no cover - """Validate the Host header against allowed values.""" - if not host: - logger.warning("Missing Host header in request") - return False - - # Check exact match first - if host in self.settings.allowed_hosts: - return True - - # Check wildcard port patterns - for allowed in self.settings.allowed_hosts: - if allowed.endswith(":*"): - # Extract base host from pattern - base_host = allowed[:-2] - # Check if the actual host starts with base host and has a port - if host.startswith(base_host + ":"): - return True - - logger.warning(f"Invalid Host header: {host}") - return False - - def _validate_origin(self, origin: str | None) -> bool: # pragma: no cover - """Validate the Origin header against allowed values.""" - # Origin can be absent for same-origin requests - if not origin: - return True - - # Check exact match first - if origin in self.settings.allowed_origins: - return True - - # Check wildcard port patterns - for allowed in self.settings.allowed_origins: - if allowed.endswith(":*"): - # Extract base origin from pattern - base_origin = allowed[:-2] - # Check if the actual origin starts with base origin and has a port - if origin.startswith(base_origin + ":"): - return True - - logger.warning(f"Invalid Origin header: {origin}") - return False - - def _validate_content_type(self, content_type: str | None) -> bool: # pragma: no cover - """Validate the Content-Type header for POST requests.""" - if not content_type: - logger.warning("Missing Content-Type header in POST request") - return False - - # Content-Type must start with application/json - if not content_type.lower().startswith("application/json"): - logger.warning(f"Invalid Content-Type header: {content_type}") - return False - - return True - - async def validate_request(self, request: HTTPConnection, is_post: bool = False) -> Response | None: - """Validate request headers for DNS rebinding protection. - - Returns None if validation passes, or an error Response if validation fails. - """ - # Always validate Content-Type for POST requests - if is_post: # pragma: no branch - content_type = request.headers.get("content-type") - if not self._validate_content_type(content_type): # pragma: no cover - return Response("Invalid Content-Type header", status_code=400) - - # Skip remaining validation if DNS rebinding protection is disabled - if not self.settings.enable_dns_rebinding_protection: - return None - - # Validate Host header # pragma: no cover - host = request.headers.get("host") # pragma: no cover - if not self._validate_host(host): # pragma: no cover - return Response("Invalid Host header", status_code=421) # pragma: no cover - - # Validate Origin header # pragma: no cover - origin = request.headers.get("origin") # pragma: no cover - if not self._validate_origin(origin): # pragma: no cover - return Response("Invalid Origin header", status_code=403) # pragma: no cover - - return None # pragma: no cover diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/validation.py b/src/agent_workflow/mcp/sdk/src/mcp/server/validation.py deleted file mode 100644 index 2ccd705..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/validation.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Shared validation functions for server requests. - -This module provides validation logic for sampling and elicitation requests -that is shared across normal and task-augmented code paths. -""" - -from mcp.shared.exceptions import McpError -from mcp.types import ( - INVALID_PARAMS, - ClientCapabilities, - ErrorData, - SamplingMessage, - Tool, - ToolChoice, -) - - -def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> bool: - """ - Check if the client supports sampling tools capability. - - Args: - client_caps: The client's declared capabilities - - Returns: - True if client supports sampling.tools, False otherwise - """ - if client_caps is None: - return False - if client_caps.sampling is None: - return False - if client_caps.sampling.tools is None: - return False - return True - - -def validate_sampling_tools( - client_caps: ClientCapabilities | None, - tools: list[Tool] | None, - tool_choice: ToolChoice | None, -) -> None: - """ - Validate that the client supports sampling tools if tools are being used. - - Args: - client_caps: The client's declared capabilities - tools: The tools list, if provided - tool_choice: The tool choice setting, if provided - - Raises: - McpError: If tools/tool_choice are provided but client doesn't support them - """ - if tools is not None or tool_choice is not None: - if not check_sampling_tools_capability(client_caps): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message="Client does not support sampling tools capability", - ) - ) - - -def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None: - """ - Validate tool_use/tool_result message structure per SEP-1577. - - This validation ensures: - 1. Messages with tool_result content contain ONLY tool_result content - 2. tool_result messages are preceded by a message with tool_use - 3. tool_result IDs match the tool_use IDs from the previous message - - See: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577 - - Args: - messages: The list of sampling messages to validate - - Raises: - ValueError: If the message structure is invalid - """ - if not messages: - return - - last_content = messages[-1].content_as_list - has_tool_results = any(c.type == "tool_result" for c in last_content) - - previous_content = messages[-2].content_as_list if len(messages) >= 2 else None - has_previous_tool_use = previous_content and any(c.type == "tool_use" for c in previous_content) - - if has_tool_results: - # Per spec: "SamplingMessage with tool result content blocks - # MUST NOT contain other content types." - if any(c.type != "tool_result" for c in last_content): - raise ValueError("The last message must contain only tool_result content if any is present") - if previous_content is None: - raise ValueError("tool_result requires a previous message containing tool_use") - if not has_previous_tool_use: - raise ValueError("tool_result blocks do not match any tool_use in the previous message") - - if has_previous_tool_use and previous_content: - tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"} - tool_result_ids = {c.toolUseId for c in last_content if c.type == "tool_result"} - if tool_use_ids != tool_result_ids: - raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match") diff --git a/src/agent_workflow/mcp/sdk/src/mcp/server/websocket.py b/src/agent_workflow/mcp/sdk/src/mcp/server/websocket.py deleted file mode 100644 index d3526f2..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/server/websocket.py +++ /dev/null @@ -1,89 +0,0 @@ -import logging -from contextlib import asynccontextmanager - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic_core import ValidationError -from starlette.types import Receive, Scope, Send -from starlette.websockets import WebSocket -from typing_extensions import deprecated - -import mcp.types as types -from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings -from mcp.shared.message import SessionMessage - -logger = logging.getLogger(__name__) - - -@deprecated( # pragma: no cover - "The WebSocket server transport is deprecated and will be removed in mcp 2.0. WebSocket was never part of" - " the MCP specification; use the streamable HTTP transport instead." -) -@asynccontextmanager -async def websocket_server( - scope: Scope, - receive: Receive, - send: Send, - security_settings: TransportSecuritySettings | None = None, -): - """ - WebSocket server transport for MCP. This is an ASGI application, suitable to be - used with a framework like Starlette and a server like Hypercorn. - - Set `security_settings` to enable Host/Origin header validation before the - handshake is accepted (same settings type as the SSE and Streamable HTTP - transports). When validation fails this raises `ValueError` after rejecting - the handshake. - - Deprecated: this transport will be removed in mcp 2.0. WebSocket was never - part of the MCP specification; use the streamable HTTP transport instead. - """ - - websocket = WebSocket(scope, receive, send) - - security = TransportSecurityMiddleware(security_settings) - error_response = await security.validate_request(websocket, is_post=False) - if error_response is not None: - # Reject the handshake; the ASGI server maps a pre-accept close to HTTP 403. - await websocket.close() - raise ValueError("Request validation failed") - - await websocket.accept(subprotocol="mcp") - - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - async def ws_reader(): - try: - async with read_stream_writer: - async for msg in websocket.iter_text(): - try: - client_message = types.JSONRPCMessage.model_validate_json(msg) - except ValidationError as exc: - await read_stream_writer.send(exc) - continue - - session_message = SessionMessage(client_message) - await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: - await websocket.close() - - async def ws_writer(): - try: - async with write_stream_reader: - async for session_message in write_stream_reader: - obj = session_message.message.model_dump_json(by_alias=True, exclude_none=True) - await websocket.send_text(obj) - except anyio.ClosedResourceError: - await websocket.close() - - async with anyio.create_task_group() as tg: - tg.start_soon(ws_reader) - tg.start_soon(ws_writer) - yield (read_stream, write_stream) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/_httpx_utils.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/_httpx_utils.py deleted file mode 100644 index 945ef80..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/_httpx_utils.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Utilities for creating standardized httpx AsyncClient instances.""" - -from typing import Any, Protocol - -import httpx - -__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] - -# Default MCP timeout configuration -MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds) -MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds) - - -class McpHttpClientFactory(Protocol): # pragma: no branch - def __call__( # pragma: no branch - self, - headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: ... - - -def create_mcp_http_client( - headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, -) -> httpx.AsyncClient: - """Create a standardized httpx AsyncClient with MCP defaults. - - This function provides common defaults used throughout the MCP codebase: - - follow_redirects=True (always enabled) - - Default timeout of 30 seconds if not specified - - Args: - headers: Optional headers to include with all requests. - timeout: Request timeout as httpx.Timeout object. - Defaults to 30 seconds if not specified. - auth: Optional authentication handler. - - Returns: - Configured httpx.AsyncClient instance with MCP defaults. - - Note: - The returned AsyncClient must be used as a context manager to ensure - proper cleanup of connections. - - Examples: - # Basic usage with MCP defaults - async with create_mcp_http_client() as client: - response = await client.get("https://api.example.com") - - # With custom headers - headers = {"Authorization": "Bearer token"} - async with create_mcp_http_client(headers) as client: - response = await client.get("/endpoint") - - # With both custom headers and timeout - timeout = httpx.Timeout(60.0, read=300.0) - async with create_mcp_http_client(headers, timeout) as client: - response = await client.get("/long-request") - - # With authentication - from httpx import BasicAuth - auth = BasicAuth(username="user", password="pass") - async with create_mcp_http_client(headers, timeout, auth) as client: - response = await client.get("/protected-endpoint") - """ - # Set MCP defaults - kwargs: dict[str, Any] = { - "follow_redirects": True, - } - - # Handle timeout - if timeout is None: - kwargs["timeout"] = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) - else: - kwargs["timeout"] = timeout - - # Handle headers - if headers is not None: - kwargs["headers"] = headers - - # Handle authentication - if auth is not None: # pragma: no cover - kwargs["auth"] = auth - - return httpx.AsyncClient(**kwargs) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/auth.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/auth.py deleted file mode 100644 index a985bef..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/auth.py +++ /dev/null @@ -1,177 +0,0 @@ -from typing import Any, Literal - -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, field_validator - - -class OAuthToken(BaseModel): - """ - See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 - """ - - access_token: str - token_type: Literal["Bearer"] = "Bearer" - expires_in: int | None = None - scope: str | None = None - refresh_token: str | None = None - - @field_validator("token_type", mode="before") - @classmethod - def normalize_token_type(cls, v: str | None) -> str | None: - if isinstance(v, str): - # Bearer is title-cased in the spec, so we normalize it - # https://datatracker.ietf.org/doc/html/rfc6750#section-4 - return v.title() - return v # pragma: no cover - - -class InvalidScopeError(Exception): - def __init__(self, message: str): - self.message = message - - -class InvalidRedirectUriError(Exception): - def __init__(self, message: str): - self.message = message - - -class OAuthClientMetadata(BaseModel): - """ - RFC 7591 OAuth 2.0 Dynamic Client Registration metadata. - See https://datatracker.ietf.org/doc/html/rfc7591#section-2 - for the full specification. - """ - - redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) - # supported auth methods for the token endpoint - token_endpoint_auth_method: ( - Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] | None - ) = None - # supported grant_types of this implementation - grant_types: list[ - Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str - ] = [ - "authorization_code", - "refresh_token", - ] - # The MCP spec requires the "code" response type, but OAuth - # servers may also return additional types they support - response_types: list[str] = ["code"] - scope: str | None = None - - # these fields are currently unused, but we support & store them for potential - # future use - client_name: str | None = None - client_uri: AnyHttpUrl | None = None - logo_uri: AnyHttpUrl | None = None - contacts: list[str] | None = None - tos_uri: AnyHttpUrl | None = None - policy_uri: AnyHttpUrl | None = None - jwks_uri: AnyHttpUrl | None = None - jwks: Any | None = None - software_id: str | None = None - software_version: str | None = None - - @field_validator( - "client_uri", - "logo_uri", - "tos_uri", - "policy_uri", - "jwks_uri", - mode="before", - ) - @classmethod - def _empty_string_optional_url_to_none(cls, v: object) -> object: - # RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers - # echo omitted metadata back as "" instead of dropping the keys, which - # AnyHttpUrl would otherwise reject — throwing away an otherwise valid - # registration response. Treat "" as absent. - if v == "": - return None - return v - - def validate_scope(self, requested_scope: str | None) -> list[str] | None: - if requested_scope is None: - return None - requested_scopes = requested_scope.split(" ") - allowed_scopes = [] if self.scope is None else self.scope.split(" ") - for scope in requested_scopes: - if scope not in allowed_scopes: # pragma: no branch - raise InvalidScopeError(f"Client was not registered with scope {scope}") - return requested_scopes # pragma: no cover - - def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: - if redirect_uri is not None: - # Validate redirect_uri against client's registered redirect URIs - if self.redirect_uris is None or redirect_uri not in self.redirect_uris: - raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client") - return redirect_uri - elif self.redirect_uris is not None and len(self.redirect_uris) == 1: - return self.redirect_uris[0] - else: - raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs") - - -class OAuthClientInformationFull(OAuthClientMetadata): - """ - RFC 7591 OAuth 2.0 Dynamic Client Registration full response - (client information plus metadata). - """ - - client_id: str | None = None - client_secret: str | None = None - client_id_issued_at: int | None = None - client_secret_expires_at: int | None = None - - -class OAuthMetadata(BaseModel): - """ - RFC 8414 OAuth 2.0 Authorization Server Metadata. - See https://datatracker.ietf.org/doc/html/rfc8414#section-2 - """ - - issuer: AnyHttpUrl - authorization_endpoint: AnyHttpUrl - token_endpoint: AnyHttpUrl - registration_endpoint: AnyHttpUrl | None = None - scopes_supported: list[str] | None = None - response_types_supported: list[str] = ["code"] - response_modes_supported: list[str] | None = None - grant_types_supported: list[str] | None = None - token_endpoint_auth_methods_supported: list[str] | None = None - token_endpoint_auth_signing_alg_values_supported: list[str] | None = None - service_documentation: AnyHttpUrl | None = None - ui_locales_supported: list[str] | None = None - op_policy_uri: AnyHttpUrl | None = None - op_tos_uri: AnyHttpUrl | None = None - revocation_endpoint: AnyHttpUrl | None = None - revocation_endpoint_auth_methods_supported: list[str] | None = None - revocation_endpoint_auth_signing_alg_values_supported: list[str] | None = None - introspection_endpoint: AnyHttpUrl | None = None - introspection_endpoint_auth_methods_supported: list[str] | None = None - introspection_endpoint_auth_signing_alg_values_supported: list[str] | None = None - code_challenge_methods_supported: list[str] | None = None - client_id_metadata_document_supported: bool | None = None - - -class ProtectedResourceMetadata(BaseModel): - """ - RFC 9728 OAuth 2.0 Protected Resource Metadata. - See https://datatracker.ietf.org/doc/html/rfc9728#section-2 - """ - - resource: AnyHttpUrl - authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) - jwks_uri: AnyHttpUrl | None = None - scopes_supported: list[str] | None = None - bearer_methods_supported: list[str] | None = Field(default=["header"]) # MCP only supports header method - resource_signing_alg_values_supported: list[str] | None = None - resource_name: str | None = None - resource_documentation: AnyHttpUrl | None = None - resource_policy_uri: AnyHttpUrl | None = None - resource_tos_uri: AnyHttpUrl | None = None - # tls_client_certificate_bound_access_tokens default is False, but ommited here for clarity - tls_client_certificate_bound_access_tokens: bool | None = None - authorization_details_types_supported: list[str] | None = None - dpop_signing_alg_values_supported: list[str] | None = None - # dpop_bound_access_tokens_required default is False, but ommited here for clarity - dpop_bound_access_tokens_required: bool | None = None diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/auth_utils.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/auth_utils.py deleted file mode 100644 index 3ba880f..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/auth_utils.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707) and PKCE (RFC 7636).""" - -import time -from urllib.parse import urlparse, urlsplit, urlunsplit - -from pydantic import AnyUrl, HttpUrl - - -def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str: - """Convert server URL to canonical resource URL per RFC 8707. - - RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". - Returns absolute URI with lowercase scheme/host for canonical form. - - Args: - url: Server URL to convert - - Returns: - Canonical resource URL string - """ - # Convert to string if needed - url_str = str(url) - - # Parse the URL and remove fragment, create canonical form - parsed = urlsplit(url_str) - canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment="")) - - return canonical - - -def check_resource_allowed(requested_resource: str, configured_resource: str) -> bool: - """Check if a requested resource URL matches a configured resource URL. - - A requested resource matches if it has the same scheme, domain, port, - and its path starts with the configured resource's path. This allows - hierarchical matching where a token for a parent resource can be used - for child resources. - - Args: - requested_resource: The resource URL being requested - configured_resource: The resource URL that has been configured - - Returns: - True if the requested resource matches the configured resource - """ - # Parse both URLs - requested = urlparse(requested_resource) - configured = urlparse(configured_resource) - - # Compare scheme, host, and port (origin) - if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower(): - return False - - # Normalize trailing slashes before comparison so that - # "/foo" and "/foo/" are treated as equivalent. - requested_path = requested.path - configured_path = configured.path - if not requested_path.endswith("/"): - requested_path += "/" - if not configured_path.endswith("/"): - configured_path += "/" - - # Check hierarchical match: requested must start with configured path. - # The trailing-slash normalization ensures "/api123/" won't match "/api/". - return requested_path.startswith(configured_path) - - -def calculate_token_expiry(expires_in: int | str | None) -> float | None: - """Calculate token expiry timestamp from expires_in seconds. - - Args: - expires_in: Seconds until token expiration (may be string from some servers) - - Returns: - Unix timestamp when token expires, or None if no expiry specified - """ - if expires_in is None: - return None # pragma: no cover - # Defensive: handle servers that return expires_in as string - return time.time() + int(expires_in) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/context.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/context.py deleted file mode 100644 index 5cf6588..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/context.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Request context for MCP handlers. -""" - -from dataclasses import dataclass, field -from typing import Any, Generic - -from typing_extensions import TypeVar - -from mcp.shared.message import CloseSSEStreamCallback -from mcp.shared.session import BaseSession -from mcp.types import RequestId, RequestParams - -SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) -LifespanContextT = TypeVar("LifespanContextT") -RequestT = TypeVar("RequestT", default=Any) - - -@dataclass -class RequestContext(Generic[SessionT, LifespanContextT, RequestT]): - request_id: RequestId - meta: RequestParams.Meta | None - session: SessionT - lifespan_context: LifespanContextT - # NOTE: This is typed as Any to avoid circular imports. The actual type is - # mcp.server.experimental.request_context.Experimental, but importing it here - # triggers mcp.server.__init__ -> fastmcp -> tools -> back to this module. - # The Server sets this to an Experimental instance at runtime. - experimental: Any = field(default=None) - request: RequestT | None = None - close_sse_stream: CloseSSEStreamCallback | None = None - close_standalone_sse_stream: CloseSSEStreamCallback | None = None diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/exceptions.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/exceptions.py deleted file mode 100644 index 4943114..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/exceptions.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from typing import Any, cast - -from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData - - -class McpError(Exception): - """ - Exception type raised when an error arrives over an MCP connection. - """ - - error: ErrorData - - def __init__(self, error: ErrorData): - """Initialize McpError.""" - super().__init__(error.message) - self.error = error - - -class UrlElicitationRequiredError(McpError): - """ - Specialized error for when a tool requires URL mode elicitation(s) before proceeding. - - Servers can raise this error from tool handlers to indicate that the client - must complete one or more URL elicitations before the request can be processed. - - Example: - raise UrlElicitationRequiredError([ - ElicitRequestURLParams( - mode="url", - message="Authorization required for your files", - url="https://example.com/oauth/authorize", - elicitationId="auth-001" - ) - ]) - """ - - def __init__( - self, - elicitations: list[ElicitRequestURLParams], - message: str | None = None, - ): - """Initialize UrlElicitationRequiredError.""" - if message is None: - message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required" - - self._elicitations = elicitations - - error = ErrorData( - code=URL_ELICITATION_REQUIRED, - message=message, - data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]}, - ) - super().__init__(error) - - @property - def elicitations(self) -> list[ElicitRequestURLParams]: - """The list of URL elicitations required before the request can proceed.""" - return self._elicitations - - @classmethod - def from_error(cls, error: ErrorData) -> UrlElicitationRequiredError: - """Reconstruct from an ErrorData received over the wire.""" - if error.code != URL_ELICITATION_REQUIRED: - raise ValueError(f"Expected error code {URL_ELICITATION_REQUIRED}, got {error.code}") - - data = cast(dict[str, Any], error.data or {}) - raw_elicitations = cast(list[dict[str, Any]], data.get("elicitations", [])) - elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations] - return cls(elicitations, error.message) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/__init__.py deleted file mode 100644 index 9b1b147..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Pure experimental MCP features (no server dependencies). - -WARNING: These APIs are experimental and may change without notice. - -For server-integrated experimental features, use mcp.server.experimental. -""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/__init__.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/__init__.py deleted file mode 100644 index 37d81af..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Pure task state management for MCP. - -WARNING: These APIs are experimental and may change without notice. - -Import directly from submodules: -- mcp.shared.experimental.tasks.store.TaskStore -- mcp.shared.experimental.tasks.context.TaskContext -- mcp.shared.experimental.tasks.in_memory_task_store.InMemoryTaskStore -- mcp.shared.experimental.tasks.message_queue.TaskMessageQueue -- mcp.shared.experimental.tasks.helpers.is_terminal -""" diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/capabilities.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/capabilities.py deleted file mode 100644 index 307fcdd..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/capabilities.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -Tasks capability checking utilities. - -This module provides functions for checking and requiring task-related -capabilities. All tasks capability logic is centralized here to keep -the main session code clean. - -WARNING: These APIs are experimental and may change without notice. -""" - -from mcp.shared.exceptions import McpError -from mcp.types import ( - INVALID_REQUEST, - ClientCapabilities, - ClientTasksCapability, - ErrorData, -) - - -def check_tasks_capability( - required: ClientTasksCapability, - client: ClientTasksCapability, -) -> bool: - """ - Check if client's tasks capability matches the required capability. - - Args: - required: The capability being checked for - client: The client's declared capabilities - - Returns: - True if client has the required capability, False otherwise - """ - if required.requests is None: - return True - if client.requests is None: - return False - - # Check elicitation.create - if required.requests.elicitation is not None: - if client.requests.elicitation is None: - return False - if required.requests.elicitation.create is not None: - if client.requests.elicitation.create is None: - return False - - # Check sampling.createMessage - if required.requests.sampling is not None: - if client.requests.sampling is None: - return False - if required.requests.sampling.createMessage is not None: - if client.requests.sampling.createMessage is None: - return False - - return True - - -def has_task_augmented_elicitation(caps: ClientCapabilities) -> bool: - """Check if capabilities include task-augmented elicitation support.""" - if caps.tasks is None: - return False - if caps.tasks.requests is None: - return False - if caps.tasks.requests.elicitation is None: - return False - return caps.tasks.requests.elicitation.create is not None - - -def has_task_augmented_sampling(caps: ClientCapabilities) -> bool: - """Check if capabilities include task-augmented sampling support.""" - if caps.tasks is None: - return False - if caps.tasks.requests is None: - return False - if caps.tasks.requests.sampling is None: - return False - return caps.tasks.requests.sampling.createMessage is not None - - -def require_task_augmented_elicitation(client_caps: ClientCapabilities | None) -> None: - """ - Raise McpError if client doesn't support task-augmented elicitation. - - Args: - client_caps: The client's declared capabilities, or None if not initialized - - Raises: - McpError: If client doesn't support task-augmented elicitation - """ - if client_caps is None or not has_task_augmented_elicitation(client_caps): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support task-augmented elicitation", - ) - ) - - -def require_task_augmented_sampling(client_caps: ClientCapabilities | None) -> None: - """ - Raise McpError if client doesn't support task-augmented sampling. - - Args: - client_caps: The client's declared capabilities, or None if not initialized - - Raises: - McpError: If client doesn't support task-augmented sampling - """ - if client_caps is None or not has_task_augmented_sampling(client_caps): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support task-augmented sampling", - ) - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/context.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/context.py deleted file mode 100644 index 12d1595..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/context.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -TaskContext - Pure task state management. - -This module provides TaskContext, which manages task state without any -server/session dependencies. It can be used standalone for distributed -workers or wrapped by ServerTaskContext for full server integration. -""" - -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, Result, Task - - -class TaskContext: - """ - Pure task state management - no session dependencies. - - This class handles: - - Task state (status, result) - - Cancellation tracking - - Store interactions - - For server-integrated features (elicit, create_message, notifications), - use ServerTaskContext from mcp.server.experimental. - - Example (distributed worker): - async def worker_job(task_id: str): - store = RedisTaskStore(redis_url) - task = await store.get_task(task_id) - ctx = TaskContext(task=task, store=store) - - await ctx.update_status("Working...") - result = await do_work() - await ctx.complete(result) - """ - - def __init__(self, task: Task, store: TaskStore): - self._task = task - self._store = store - self._cancelled = False - - @property - def task_id(self) -> str: - """The task identifier.""" - return self._task.taskId - - @property - def task(self) -> Task: - """The current task state.""" - return self._task - - @property - def is_cancelled(self) -> bool: - """Whether cancellation has been requested.""" - return self._cancelled - - def request_cancellation(self) -> None: - """ - Request cancellation of this task. - - This sets is_cancelled=True. Task work should check this - periodically and exit gracefully if set. - """ - self._cancelled = True - - async def update_status(self, message: str) -> None: - """ - Update the task's status message. - - Args: - message: The new status message - """ - self._task = await self._store.update_task( - self.task_id, - status_message=message, - ) - - async def complete(self, result: Result) -> None: - """ - Mark the task as completed with the given result. - - Args: - result: The task result - """ - await self._store.store_result(self.task_id, result) - self._task = await self._store.update_task( - self.task_id, - status=TASK_STATUS_COMPLETED, - ) - - async def fail(self, error: str) -> None: - """ - Mark the task as failed with an error message. - - Args: - error: The error message - """ - self._task = await self._store.update_task( - self.task_id, - status=TASK_STATUS_FAILED, - status_message=error, - ) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/helpers.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/helpers.py deleted file mode 100644 index 5c87f9e..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/helpers.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -Helper functions for pure task management. - -These helpers work with pure TaskContext and don't require server dependencies. -For server-integrated task helpers, use mcp.server.experimental. -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from datetime import datetime, timezone -from uuid import uuid4 - -from mcp.shared.exceptions import McpError -from mcp.shared.experimental.tasks.context import TaskContext -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import ( - INVALID_PARAMS, - TASK_STATUS_CANCELLED, - TASK_STATUS_COMPLETED, - TASK_STATUS_FAILED, - TASK_STATUS_WORKING, - CancelTaskResult, - ErrorData, - Task, - TaskMetadata, - TaskStatus, -) - -# Metadata key for model-immediate-response (per MCP spec) -# Servers MAY include this in CreateTaskResult._meta to provide an immediate -# response string while the task executes in the background. -MODEL_IMMEDIATE_RESPONSE_KEY = "io.modelcontextprotocol/model-immediate-response" - -# Metadata key for associating requests with a task (per MCP spec) -RELATED_TASK_METADATA_KEY = "io.modelcontextprotocol/related-task" - - -def is_terminal(status: TaskStatus) -> bool: - """ - Check if a task status represents a terminal state. - - Terminal states are those where the task has finished and will not change. - - Args: - status: The task status to check - - Returns: - True if the status is terminal (completed, failed, or cancelled) - """ - return status in (TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, TASK_STATUS_CANCELLED) - - -async def cancel_task( - store: TaskStore, - task_id: str, -) -> CancelTaskResult: - """ - Cancel a task with spec-compliant validation. - - Per spec: "Receivers MUST reject cancellation of terminal status tasks - with -32602 (Invalid params)" - - This helper validates that the task exists and is not in a terminal state - before setting it to "cancelled". - - Args: - store: The task store - task_id: The task identifier to cancel - - Returns: - CancelTaskResult with the cancelled task state - - Raises: - McpError: With INVALID_PARAMS (-32602) if: - - Task does not exist - - Task is already in a terminal state (completed, failed, cancelled) - - Example: - @server.experimental.cancel_task() - async def handle_cancel(request: CancelTaskRequest) -> CancelTaskResult: - return await cancel_task(store, request.params.taskId) - """ - task = await store.get_task(task_id) - if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {task_id}", - ) - ) - - if is_terminal(task.status): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Cannot cancel task in terminal state '{task.status}'", - ) - ) - - # Update task to cancelled status - cancelled_task = await store.update_task(task_id, status=TASK_STATUS_CANCELLED) - return CancelTaskResult(**cancelled_task.model_dump()) - - -def generate_task_id() -> str: - """Generate a unique task ID.""" - return str(uuid4()) - - -def create_task_state( - metadata: TaskMetadata, - task_id: str | None = None, -) -> Task: - """ - Create a Task object with initial state. - - This is a helper for TaskStore implementations. - - Args: - metadata: Task metadata - task_id: Optional task ID (generated if not provided) - - Returns: - A new Task in "working" status - """ - now = datetime.now(timezone.utc) - return Task( - taskId=task_id or generate_task_id(), - status=TASK_STATUS_WORKING, - createdAt=now, - lastUpdatedAt=now, - ttl=metadata.ttl, - pollInterval=500, # Default 500ms poll interval - ) - - -@asynccontextmanager -async def task_execution( - task_id: str, - store: TaskStore, -) -> AsyncIterator[TaskContext]: - """ - Context manager for safe task execution (pure, no server dependencies). - - Loads a task from the store and provides a TaskContext for the work. - If an unhandled exception occurs, the task is automatically marked as failed - and the exception is suppressed (since the failure is captured in task state). - - This is useful for distributed workers that don't have a server session. - - Args: - task_id: The task identifier to execute - store: The task store (must be accessible by the worker) - - Yields: - TaskContext for updating status and completing/failing the task - - Raises: - ValueError: If the task is not found in the store - - Example (distributed worker): - async def worker_process(task_id: str): - store = RedisTaskStore(redis_url) - async with task_execution(task_id, store) as ctx: - await ctx.update_status("Working...") - result = await do_work() - await ctx.complete(result) - """ - task = await store.get_task(task_id) - if task is None: - raise ValueError(f"Task {task_id} not found") - - ctx = TaskContext(task, store) - try: - yield ctx - except Exception as e: - # Auto-fail the task if an exception occurs and task isn't already terminal - # Exception is suppressed since failure is captured in task state - if not is_terminal(ctx.task.status): - await ctx.fail(str(e)) - # Don't re-raise - the failure is recorded in task state diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/in_memory_task_store.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/in_memory_task_store.py deleted file mode 100644 index 7b630ce..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/in_memory_task_store.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -In-memory implementation of TaskStore for demonstration purposes. - -This implementation stores all tasks in memory and provides automatic cleanup -based on the TTL duration specified in the task metadata using lazy expiration. - -Note: This is not suitable for production use as all data is lost on restart. -For production, consider implementing TaskStore with a database or distributed cache. -""" - -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone - -import anyio - -from mcp.shared.experimental.tasks.helpers import create_task_state, is_terminal -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import Result, Task, TaskMetadata, TaskStatus - - -@dataclass -class StoredTask: - """Internal storage representation of a task.""" - - task: Task - result: Result | None = None - # Time when this task should be removed (None = never) - expires_at: datetime | None = field(default=None) - - -class InMemoryTaskStore(TaskStore): - """ - A simple in-memory implementation of TaskStore. - - Features: - - Automatic TTL-based cleanup (lazy expiration) - - Thread-safe for single-process async use - - Pagination support for list_tasks - - Limitations: - - All data lost on restart - - Not suitable for distributed systems - - No persistence - - For production, implement TaskStore with Redis, PostgreSQL, etc. - """ - - def __init__(self, page_size: int = 10) -> None: - self._tasks: dict[str, StoredTask] = {} - self._page_size = page_size - self._update_events: dict[str, anyio.Event] = {} - - def _calculate_expiry(self, ttl_ms: int | None) -> datetime | None: - """Calculate expiry time from TTL in milliseconds.""" - if ttl_ms is None: - return None - return datetime.now(timezone.utc) + timedelta(milliseconds=ttl_ms) - - def _is_expired(self, stored: StoredTask) -> bool: - """Check if a task has expired.""" - if stored.expires_at is None: - return False - return datetime.now(timezone.utc) >= stored.expires_at - - def _cleanup_expired(self) -> None: - """Remove all expired tasks. Called lazily during access operations.""" - expired_ids = [task_id for task_id, stored in self._tasks.items() if self._is_expired(stored)] - for task_id in expired_ids: - del self._tasks[task_id] - - async def create_task( - self, - metadata: TaskMetadata, - task_id: str | None = None, - ) -> Task: - """Create a new task with the given metadata.""" - # Cleanup expired tasks on access - self._cleanup_expired() - - task = create_task_state(metadata, task_id) - - if task.taskId in self._tasks: - raise ValueError(f"Task with ID {task.taskId} already exists") - - stored = StoredTask( - task=task, - expires_at=self._calculate_expiry(metadata.ttl), - ) - self._tasks[task.taskId] = stored - - # Return a copy to prevent external modification - return Task(**task.model_dump()) - - async def get_task(self, task_id: str) -> Task | None: - """Get a task by ID.""" - # Cleanup expired tasks on access - self._cleanup_expired() - - stored = self._tasks.get(task_id) - if stored is None: - return None - - # Return a copy to prevent external modification - return Task(**stored.task.model_dump()) - - async def update_task( - self, - task_id: str, - status: TaskStatus | None = None, - status_message: str | None = None, - ) -> Task: - """Update a task's status and/or message.""" - stored = self._tasks.get(task_id) - if stored is None: - raise ValueError(f"Task with ID {task_id} not found") - - # Per spec: Terminal states MUST NOT transition to any other status - if status is not None and status != stored.task.status and is_terminal(stored.task.status): - raise ValueError(f"Cannot transition from terminal status '{stored.task.status}'") - - status_changed = False - if status is not None and stored.task.status != status: - stored.task.status = status - status_changed = True - - if status_message is not None: - stored.task.statusMessage = status_message - - # Update lastUpdatedAt on any change - stored.task.lastUpdatedAt = datetime.now(timezone.utc) - - # If task is now terminal and has TTL, reset expiry timer - if status is not None and is_terminal(status) and stored.task.ttl is not None: - stored.expires_at = self._calculate_expiry(stored.task.ttl) - - # Notify waiters if status changed - if status_changed: - await self.notify_update(task_id) - - return Task(**stored.task.model_dump()) - - async def store_result(self, task_id: str, result: Result) -> None: - """Store the result for a task.""" - stored = self._tasks.get(task_id) - if stored is None: - raise ValueError(f"Task with ID {task_id} not found") - - stored.result = result - - async def get_result(self, task_id: str) -> Result | None: - """Get the stored result for a task.""" - stored = self._tasks.get(task_id) - if stored is None: - return None - - return stored.result - - async def list_tasks( - self, - cursor: str | None = None, - ) -> tuple[list[Task], str | None]: - """List tasks with pagination.""" - # Cleanup expired tasks on access - self._cleanup_expired() - - all_task_ids = list(self._tasks.keys()) - - start_index = 0 - if cursor is not None: - try: - cursor_index = all_task_ids.index(cursor) - start_index = cursor_index + 1 - except ValueError: - raise ValueError(f"Invalid cursor: {cursor}") - - page_task_ids = all_task_ids[start_index : start_index + self._page_size] - tasks = [Task(**self._tasks[tid].task.model_dump()) for tid in page_task_ids] - - # Determine next cursor - next_cursor = None - if start_index + self._page_size < len(all_task_ids) and page_task_ids: - next_cursor = page_task_ids[-1] - - return tasks, next_cursor - - async def delete_task(self, task_id: str) -> bool: - """Delete a task.""" - if task_id not in self._tasks: - return False - - del self._tasks[task_id] - return True - - async def wait_for_update(self, task_id: str) -> None: - """Wait until the task status changes.""" - if task_id not in self._tasks: - raise ValueError(f"Task with ID {task_id} not found") - - # Create a fresh event for waiting (anyio.Event can't be cleared) - self._update_events[task_id] = anyio.Event() - event = self._update_events[task_id] - await event.wait() - - async def notify_update(self, task_id: str) -> None: - """Signal that a task has been updated.""" - if task_id in self._update_events: - self._update_events[task_id].set() - - # --- Testing/debugging helpers --- - - def cleanup(self) -> None: - """Cleanup all tasks (useful for testing or graceful shutdown).""" - self._tasks.clear() - self._update_events.clear() - - def get_all_tasks(self) -> list[Task]: - """Get all tasks (useful for debugging). Returns copies to prevent modification.""" - self._cleanup_expired() - return [Task(**stored.task.model_dump()) for stored in self._tasks.values()] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/message_queue.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/message_queue.py deleted file mode 100644 index 69b6609..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/message_queue.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -TaskMessageQueue - FIFO queue for task-related messages. - -This implements the core message queue pattern from the MCP Tasks spec. -When a handler needs to send a request (like elicitation) during a task-augmented -request, the message is enqueued instead of sent directly. Messages are delivered -to the client only through the `tasks/result` endpoint. - -This pattern enables: -1. Decoupling request handling from message delivery -2. Proper bidirectional communication via the tasks/result stream -3. Automatic status management (working <-> input_required) -""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, Literal - -import anyio - -from mcp.shared.experimental.tasks.resolver import Resolver -from mcp.types import JSONRPCNotification, JSONRPCRequest, RequestId - - -@dataclass -class QueuedMessage: - """ - A message queued for delivery via tasks/result. - - Messages are stored with their type and a resolver for requests - that expect responses. - """ - - type: Literal["request", "notification"] - """Whether this is a request (expects response) or notification (one-way).""" - - message: JSONRPCRequest | JSONRPCNotification - """The JSON-RPC message to send.""" - - timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - """When the message was enqueued.""" - - resolver: Resolver[dict[str, Any]] | None = None - """Resolver to set when response arrives (only for requests).""" - - original_request_id: RequestId | None = None - """The original request ID used internally, for routing responses back.""" - - -class TaskMessageQueue(ABC): - """ - Abstract interface for task message queuing. - - This is a FIFO queue that stores messages to be delivered via `tasks/result`. - When a task-augmented handler calls elicit() or sends a notification, the - message is enqueued here instead of being sent directly to the client. - - The `tasks/result` handler then dequeues and sends these messages through - the transport, with `relatedRequestId` set to the tasks/result request ID - so responses are routed correctly. - - Implementations can use in-memory storage, Redis, etc. - """ - - @abstractmethod - async def enqueue(self, task_id: str, message: QueuedMessage) -> None: - """ - Add a message to the queue for a task. - - Args: - task_id: The task identifier - message: The message to enqueue - """ - - @abstractmethod - async def dequeue(self, task_id: str) -> QueuedMessage | None: - """ - Remove and return the next message from the queue. - - Args: - task_id: The task identifier - - Returns: - The next message, or None if queue is empty - """ - - @abstractmethod - async def peek(self, task_id: str) -> QueuedMessage | None: - """ - Return the next message without removing it. - - Args: - task_id: The task identifier - - Returns: - The next message, or None if queue is empty - """ - - @abstractmethod - async def is_empty(self, task_id: str) -> bool: - """ - Check if the queue is empty for a task. - - Args: - task_id: The task identifier - - Returns: - True if no messages are queued - """ - - @abstractmethod - async def clear(self, task_id: str) -> list[QueuedMessage]: - """ - Remove and return all messages from the queue. - - This is useful for cleanup when a task is cancelled or completed. - - Args: - task_id: The task identifier - - Returns: - All queued messages (may be empty) - """ - - @abstractmethod - async def wait_for_message(self, task_id: str) -> None: - """ - Wait until a message is available in the queue. - - This blocks until either: - 1. A message is enqueued for this task - 2. The wait is cancelled - - Args: - task_id: The task identifier - """ - - @abstractmethod - async def notify_message_available(self, task_id: str) -> None: - """ - Signal that a message is available for a task. - - This wakes up any coroutines waiting in wait_for_message(). - - Args: - task_id: The task identifier - """ - - -class InMemoryTaskMessageQueue(TaskMessageQueue): - """ - In-memory implementation of TaskMessageQueue. - - This is suitable for single-process servers. For distributed systems, - implement TaskMessageQueue with Redis, RabbitMQ, etc. - - Features: - - FIFO ordering per task - - Async wait for message availability - - Thread-safe for single-process async use - """ - - def __init__(self) -> None: - self._queues: dict[str, list[QueuedMessage]] = {} - self._events: dict[str, anyio.Event] = {} - - def _get_queue(self, task_id: str) -> list[QueuedMessage]: - """Get or create the queue for a task.""" - if task_id not in self._queues: - self._queues[task_id] = [] - return self._queues[task_id] - - async def enqueue(self, task_id: str, message: QueuedMessage) -> None: - """Add a message to the queue.""" - queue = self._get_queue(task_id) - queue.append(message) - # Signal that a message is available - await self.notify_message_available(task_id) - - async def dequeue(self, task_id: str) -> QueuedMessage | None: - """Remove and return the next message.""" - queue = self._get_queue(task_id) - if not queue: - return None - return queue.pop(0) - - async def peek(self, task_id: str) -> QueuedMessage | None: - """Return the next message without removing it.""" - queue = self._get_queue(task_id) - if not queue: - return None - return queue[0] - - async def is_empty(self, task_id: str) -> bool: - """Check if the queue is empty.""" - queue = self._get_queue(task_id) - return len(queue) == 0 - - async def clear(self, task_id: str) -> list[QueuedMessage]: - """Remove and return all messages.""" - queue = self._get_queue(task_id) - messages = list(queue) - queue.clear() - return messages - - async def wait_for_message(self, task_id: str) -> None: - """Wait until a message is available.""" - # Check if there are already messages - if not await self.is_empty(task_id): - return - - # Create a fresh event for waiting (anyio.Event can't be cleared) - self._events[task_id] = anyio.Event() - event = self._events[task_id] - - # Double-check after creating event (avoid race condition) - if not await self.is_empty(task_id): - return - - # Wait for a new message - await event.wait() - - async def notify_message_available(self, task_id: str) -> None: - """Signal that a message is available.""" - if task_id in self._events: - self._events[task_id].set() - - def cleanup(self, task_id: str | None = None) -> None: - """ - Clean up queues and events. - - Args: - task_id: If provided, clean up only this task. Otherwise clean up all. - """ - if task_id is not None: - self._queues.pop(task_id, None) - self._events.pop(task_id, None) - else: - self._queues.clear() - self._events.clear() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/polling.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/polling.py deleted file mode 100644 index 39db2e6..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/polling.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Shared polling utilities for task operations. - -This module provides generic polling logic that works for both client→server -and server→client task polling. - -WARNING: These APIs are experimental and may change without notice. -""" - -from collections.abc import AsyncIterator, Awaitable, Callable - -import anyio - -from mcp.shared.experimental.tasks.helpers import is_terminal -from mcp.types import GetTaskResult - - -async def poll_until_terminal( - get_task: Callable[[str], Awaitable[GetTaskResult]], - task_id: str, - default_interval_ms: int = 500, -) -> AsyncIterator[GetTaskResult]: - """ - Poll a task until it reaches terminal status. - - This is a generic utility that works for both client→server and server→client - polling. The caller provides the get_task function appropriate for their direction. - - Args: - get_task: Async function that takes task_id and returns GetTaskResult - task_id: The task to poll - default_interval_ms: Fallback poll interval if server doesn't specify - - Yields: - GetTaskResult for each poll - """ - while True: - status = await get_task(task_id) - yield status - - if is_terminal(status.status): - break - - interval_ms = status.pollInterval if status.pollInterval is not None else default_interval_ms - await anyio.sleep(interval_ms / 1000) diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/resolver.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/resolver.py deleted file mode 100644 index f27425b..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/resolver.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Resolver - An anyio-compatible future-like object for async result passing. - -This provides a simple way to pass a result (or exception) from one coroutine -to another without depending on asyncio.Future. -""" - -from typing import Generic, TypeVar, cast - -import anyio - -T = TypeVar("T") - - -class Resolver(Generic[T]): - """ - A simple resolver for passing results between coroutines. - - Unlike asyncio.Future, this works with any anyio-compatible async backend. - - Usage: - resolver: Resolver[str] = Resolver() - - # In one coroutine: - resolver.set_result("hello") - - # In another coroutine: - result = await resolver.wait() # returns "hello" - """ - - def __init__(self) -> None: - self._event = anyio.Event() - self._value: T | None = None - self._exception: BaseException | None = None - - def set_result(self, value: T) -> None: - """Set the result value and wake up waiters.""" - if self._event.is_set(): - raise RuntimeError("Resolver already completed") - self._value = value - self._event.set() - - def set_exception(self, exc: BaseException) -> None: - """Set an exception and wake up waiters.""" - if self._event.is_set(): - raise RuntimeError("Resolver already completed") - self._exception = exc - self._event.set() - - async def wait(self) -> T: - """Wait for the result and return it, or raise the exception.""" - await self._event.wait() - if self._exception is not None: - raise self._exception - # If we reach here, set_result() was called, so _value is set - return cast(T, self._value) - - def done(self) -> bool: - """Return True if the resolver has been completed.""" - return self._event.is_set() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/store.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/store.py deleted file mode 100644 index 71fb451..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/experimental/tasks/store.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -TaskStore - Abstract interface for task state storage. -""" - -from abc import ABC, abstractmethod - -from mcp.types import Result, Task, TaskMetadata, TaskStatus - - -class TaskStore(ABC): - """ - Abstract interface for task state storage. - - This is a pure storage interface - it doesn't manage execution. - Implementations can use in-memory storage, databases, Redis, etc. - - All methods are async to support various backends. - """ - - @abstractmethod - async def create_task( - self, - metadata: TaskMetadata, - task_id: str | None = None, - ) -> Task: - """ - Create a new task. - - Args: - metadata: Task metadata (ttl, etc.) - task_id: Optional task ID. If None, implementation should generate one. - - Returns: - The created Task with status="working" - - Raises: - ValueError: If task_id already exists - """ - - @abstractmethod - async def get_task(self, task_id: str) -> Task | None: - """ - Get a task by ID. - - Args: - task_id: The task identifier - - Returns: - The Task, or None if not found - """ - - @abstractmethod - async def update_task( - self, - task_id: str, - status: TaskStatus | None = None, - status_message: str | None = None, - ) -> Task: - """ - Update a task's status and/or message. - - Args: - task_id: The task identifier - status: New status (if changing) - status_message: New status message (if changing) - - Returns: - The updated Task - - Raises: - ValueError: If task not found - ValueError: If attempting to transition from a terminal status - (completed, failed, cancelled). Per spec, terminal states - MUST NOT transition to any other status. - """ - - @abstractmethod - async def store_result(self, task_id: str, result: Result) -> None: - """ - Store the result for a task. - - Args: - task_id: The task identifier - result: The result to store - - Raises: - ValueError: If task not found - """ - - @abstractmethod - async def get_result(self, task_id: str) -> Result | None: - """ - Get the stored result for a task. - - Args: - task_id: The task identifier - - Returns: - The stored Result, or None if not available - """ - - @abstractmethod - async def list_tasks( - self, - cursor: str | None = None, - ) -> tuple[list[Task], str | None]: - """ - List tasks with pagination. - - Args: - cursor: Optional cursor for pagination - - Returns: - Tuple of (tasks, next_cursor). next_cursor is None if no more pages. - """ - - @abstractmethod - async def delete_task(self, task_id: str) -> bool: - """ - Delete a task. - - Args: - task_id: The task identifier - - Returns: - True if deleted, False if not found - """ - - @abstractmethod - async def wait_for_update(self, task_id: str) -> None: - """ - Wait until the task status changes. - - This blocks until either: - 1. The task status changes - 2. The wait is cancelled - - Used by tasks/result to wait for task completion or status changes. - - Args: - task_id: The task identifier - - Raises: - ValueError: If task not found - """ - - @abstractmethod - async def notify_update(self, task_id: str) -> None: - """ - Signal that a task has been updated. - - This wakes up any coroutines waiting in wait_for_update(). - - Args: - task_id: The task identifier - """ diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/memory.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/memory.py deleted file mode 100644 index 06d404e..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/memory.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -In-memory transports -""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from datetime import timedelta -from typing import Any - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream - -import mcp.types as types -from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT -from mcp.server import Server -from mcp.server.fastmcp import FastMCP -from mcp.shared.message import SessionMessage - -MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] - - -@asynccontextmanager -async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageStream, MessageStream], None]: - """ - Creates a pair of bidirectional memory streams for client-server communication. - - Returns: - A tuple of (client_streams, server_streams) where each is a tuple of - (read_stream, write_stream) - """ - # Create streams for both directions - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) - - client_streams = (server_to_client_receive, client_to_server_send) - server_streams = (client_to_server_receive, server_to_client_send) - - async with ( - server_to_client_receive, - client_to_server_send, - client_to_server_receive, - server_to_client_send, - ): - yield client_streams, server_streams - - -@asynccontextmanager -async def create_connected_server_and_client_session( - server: Server[Any] | FastMCP, - read_timeout_seconds: timedelta | None = None, - sampling_callback: SamplingFnT | None = None, - list_roots_callback: ListRootsFnT | None = None, - logging_callback: LoggingFnT | None = None, - message_handler: MessageHandlerFnT | None = None, - client_info: types.Implementation | None = None, - raise_exceptions: bool = False, - elicitation_callback: ElicitationFnT | None = None, -) -> AsyncGenerator[ClientSession, None]: - """Creates a ClientSession that is connected to a running MCP server.""" - - # TODO(Marcelo): we should have a proper `Client` that can use this "in-memory transport", - # and we should expose a method in the `FastMCP` so we don't access a private attribute. - if isinstance(server, FastMCP): # pragma: no cover - server = server._mcp_server # type: ignore[reportPrivateUsage] - - async with create_client_server_memory_streams() as (client_streams, server_streams): - client_read, client_write = client_streams - server_read, server_write = server_streams - - # Create a cancel scope for the server task - async with anyio.create_task_group() as tg: - tg.start_soon( - lambda: server.run( - server_read, - server_write, - server.create_initialization_options(), - raise_exceptions=raise_exceptions, - ) - ) - - try: - async with ClientSession( - read_stream=client_read, - write_stream=client_write, - read_timeout_seconds=read_timeout_seconds, - sampling_callback=sampling_callback, - list_roots_callback=list_roots_callback, - logging_callback=logging_callback, - message_handler=message_handler, - client_info=client_info, - elicitation_callback=elicitation_callback, - ) as client_session: - await client_session.initialize() - yield client_session - finally: # pragma: no cover - tg.cancel_scope.cancel() diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/message.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/message.py deleted file mode 100644 index 81503ea..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/message.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Message wrapper with metadata support. - -This module defines a wrapper type that combines JSONRPCMessage with metadata -to support transport-specific features like resumability. -""" - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass - -from mcp.types import JSONRPCMessage, RequestId - -ResumptionToken = str - -ResumptionTokenUpdateCallback = Callable[[ResumptionToken], Awaitable[None]] - -# Callback type for closing SSE streams without terminating -CloseSSEStreamCallback = Callable[[], Awaitable[None]] - - -@dataclass -class ClientMessageMetadata: - """Metadata specific to client messages.""" - - resumption_token: ResumptionToken | None = None - on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None - - -@dataclass -class ServerMessageMetadata: - """Metadata specific to server messages.""" - - related_request_id: RequestId | None = None - # Request-specific context (e.g., headers, auth info) - request_context: object | None = None - # Callback to close SSE stream for the current request without terminating - close_sse_stream: CloseSSEStreamCallback | None = None - # Callback to close the standalone GET SSE stream (for unsolicited notifications) - close_standalone_sse_stream: CloseSSEStreamCallback | None = None - - -MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None - - -@dataclass -class SessionMessage: - """A message with specific metadata for transport-specific features.""" - - message: JSONRPCMessage - metadata: MessageMetadata = None diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/metadata_utils.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/metadata_utils.py deleted file mode 100644 index e3f49da..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/metadata_utils.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Utility functions for working with metadata in MCP types. - -These utilities are primarily intended for client-side usage to properly display -human-readable names in user interfaces in a spec compliant way. -""" - -from mcp.types import Implementation, Prompt, Resource, ResourceTemplate, Tool - - -def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implementation) -> str: - """ - Get the display name for an MCP object with proper precedence. - - This is a client-side utility function designed to help MCP clients display - human-readable names in their user interfaces. When servers provide a 'title' - field, it should be preferred over the programmatic 'name' field for display. - - For tools: title > annotations.title > name - For other objects: title > name - - Example: - # In a client displaying available tools - tools = await session.list_tools() - for tool in tools.tools: - display_name = get_display_name(tool) - print(f"Available tool: {display_name}") - - Args: - obj: An MCP object with name and optional title fields - - Returns: - The display name to use for UI presentation - """ - if isinstance(obj, Tool): - # Tools have special precedence: title > annotations.title > name - if hasattr(obj, "title") and obj.title is not None: - return obj.title - if obj.annotations and hasattr(obj.annotations, "title") and obj.annotations.title is not None: - return obj.annotations.title - return obj.name - else: - # All other objects: title > name - if hasattr(obj, "title") and obj.title is not None: - return obj.title - return obj.name diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/progress.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/progress.py deleted file mode 100644 index a230c58..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/progress.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass, field -from typing import Generic - -from pydantic import BaseModel - -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.shared.session import ( - BaseSession, - ReceiveNotificationT, - ReceiveRequestT, - SendNotificationT, - SendRequestT, - SendResultT, -) -from mcp.types import ProgressToken - - -class Progress(BaseModel): - progress: float - total: float | None - - -@dataclass -class ProgressContext(Generic[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT]): - session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT] - progress_token: ProgressToken - total: float | None - current: float = field(default=0.0, init=False) - - async def progress(self, amount: float, message: str | None = None) -> None: - self.current += amount - - await self.session.send_progress_notification( - self.progress_token, self.current, total=self.total, message=message - ) - - -@contextmanager -def progress( - ctx: RequestContext[ - BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], - LifespanContextT, - ], - total: float | None = None, -) -> Generator[ - ProgressContext[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], - None, -]: - if ctx.meta is None or ctx.meta.progressToken is None: # pragma: no cover - raise ValueError("No progress token provided") - - progress_ctx = ProgressContext(ctx.session, ctx.meta.progressToken, total) - try: - yield progress_ctx - finally: - pass diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/response_router.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/response_router.py deleted file mode 100644 index 3179615..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/response_router.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -ResponseRouter - Protocol for pluggable response routing. - -This module defines a protocol for routing JSON-RPC responses to alternative -handlers before falling back to the default response stream mechanism. - -The primary use case is task-augmented requests: when a TaskSession enqueues -a request (like elicitation), the response needs to be routed back to the -waiting resolver instead of the normal response stream. - -Design: -- Protocol-based for testability and flexibility -- Returns bool to indicate if response was handled -- Supports both success responses and errors -""" - -from typing import Any, Protocol - -from mcp.types import ErrorData, RequestId - - -class ResponseRouter(Protocol): - """ - Protocol for routing responses to alternative handlers. - - Implementations check if they have a pending request for the given ID - and deliver the response/error to the appropriate handler. - - Example: - class TaskResultHandler(ResponseRouter): - def route_response(self, request_id, response): - resolver = self._pending_requests.pop(request_id, None) - if resolver: - resolver.set_result(response) - return True - return False - """ - - def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: - """ - Try to route a response to a pending request handler. - - Args: - request_id: The JSON-RPC request ID from the response - response: The response result data - - Returns: - True if the response was handled, False otherwise - """ - ... # pragma: no cover - - def route_error(self, request_id: RequestId, error: ErrorData) -> bool: - """ - Try to route an error to a pending request handler. - - Args: - request_id: The JSON-RPC request ID from the error response - error: The error data - - Returns: - True if the error was handled, False otherwise - """ - ... # pragma: no cover diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/session.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/session.py deleted file mode 100644 index 35a83fc..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/session.py +++ /dev/null @@ -1,552 +0,0 @@ -import logging -from collections.abc import Callable -from contextlib import AsyncExitStack -from datetime import timedelta -from types import TracebackType -from typing import Any, Generic, Protocol, TypeVar - -import anyio -import httpx -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import BaseModel -from typing_extensions import Self - -from mcp.shared.exceptions import McpError -from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage -from mcp.shared.response_router import ResponseRouter -from mcp.types import ( - CONNECTION_CLOSED, - INVALID_PARAMS, - CancelledNotification, - ClientNotification, - ClientRequest, - ClientResult, - ErrorData, - JSONRPCError, - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - ProgressNotification, - RequestParams, - ServerNotification, - ServerRequest, - ServerResult, -) - -SendRequestT = TypeVar("SendRequestT", ClientRequest, ServerRequest) -SendResultT = TypeVar("SendResultT", ClientResult, ServerResult) -SendNotificationT = TypeVar("SendNotificationT", ClientNotification, ServerNotification) -ReceiveRequestT = TypeVar("ReceiveRequestT", ClientRequest, ServerRequest) -ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) -ReceiveNotificationT = TypeVar("ReceiveNotificationT", ClientNotification, ServerNotification) - -RequestId = str | int - - -class ProgressFnT(Protocol): - """Protocol for progress notification callbacks.""" - - async def __call__( - self, progress: float, total: float | None, message: str | None - ) -> None: ... # pragma: no branch - - -class RequestResponder(Generic[ReceiveRequestT, SendResultT]): - """Handles responding to MCP requests and manages request lifecycle. - - This class MUST be used as a context manager to ensure proper cleanup and - cancellation handling: - - Example: - with request_responder as resp: - await resp.respond(result) - - The context manager ensures: - 1. Proper cancellation scope setup and cleanup - 2. Request completion tracking - 3. Cleanup of in-flight requests - """ - - def __init__( - self, - request_id: RequestId, - request_meta: RequestParams.Meta | None, - request: ReceiveRequestT, - session: """BaseSession[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT - ]""", - on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any], - message_metadata: MessageMetadata = None, - ) -> None: - self.request_id = request_id - self.request_meta = request_meta - self.request = request - self.message_metadata = message_metadata - self._session = session - self._completed = False - self._cancel_scope = anyio.CancelScope() - self._on_complete = on_complete - self._entered = False # Track if we're in a context manager - - def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]": - """Enter the context manager, enabling request cancellation tracking.""" - self._entered = True - self._cancel_scope = anyio.CancelScope() - self._cancel_scope.__enter__() - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Exit the context manager, performing cleanup and notifying completion.""" - try: - if self._completed: - self._on_complete(self) - finally: - self._entered = False - if not self._cancel_scope: # pragma: no cover - raise RuntimeError("No active cancel scope") - self._cancel_scope.__exit__(exc_type, exc_val, exc_tb) - - async def respond(self, response: SendResultT | ErrorData) -> None: - """Send a response for this request. - - Must be called within a context manager block. - Raises: - RuntimeError: If not used within a context manager - AssertionError: If request was already responded to - """ - if not self._entered: # pragma: no cover - raise RuntimeError("RequestResponder must be used as a context manager") - assert not self._completed, "Request already responded to" - - if not self.cancelled: # pragma: no branch - self._completed = True - - await self._session._send_response( # type: ignore[reportPrivateUsage] - request_id=self.request_id, response=response - ) - - async def cancel(self) -> None: - """Cancel this request and mark it as completed.""" - if not self._entered: # pragma: no cover - raise RuntimeError("RequestResponder must be used as a context manager") - if not self._cancel_scope: # pragma: no cover - raise RuntimeError("No active cancel scope") - - self._cancel_scope.cancel() - self._completed = True # Mark as completed so it's removed from in_flight - # Send an error response to indicate cancellation - await self._session._send_response( # type: ignore[reportPrivateUsage] - request_id=self.request_id, - response=ErrorData(code=0, message="Request cancelled", data=None), - ) - - @property - def in_flight(self) -> bool: # pragma: no cover - return not self._completed and not self.cancelled - - @property - def cancelled(self) -> bool: # pragma: no cover - return self._cancel_scope.cancel_called - - -class BaseSession( - Generic[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT, - ], -): - """ - Implements an MCP "session" on top of read/write streams, including features - like request/response linking, notifications, and progress. - - This class is an async context manager that automatically starts processing - messages when entered. - """ - - _response_streams: dict[RequestId, MemoryObjectSendStream[JSONRPCResponse | JSONRPCError]] - _request_id: int - _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]] - _progress_callbacks: dict[RequestId, ProgressFnT] - _response_routers: list["ResponseRouter"] - - def __init__( - self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], - receive_request_type: type[ReceiveRequestT], - receive_notification_type: type[ReceiveNotificationT], - # If none, reading will never time out - read_timeout_seconds: timedelta | None = None, - ) -> None: - self._read_stream = read_stream - self._write_stream = write_stream - self._response_streams = {} - self._request_id = 0 - self._receive_request_type = receive_request_type - self._receive_notification_type = receive_notification_type - self._session_read_timeout_seconds = read_timeout_seconds - self._in_flight = {} - self._progress_callbacks = {} - self._response_routers = [] - self._exit_stack = AsyncExitStack() - - def add_response_router(self, router: ResponseRouter) -> None: - """ - Register a response router to handle responses for non-standard requests. - - Response routers are checked in order before falling back to the default - response stream mechanism. This is used by TaskResultHandler to route - responses for queued task requests back to their resolvers. - - WARNING: This is an experimental API that may change without notice. - - Args: - router: A ResponseRouter implementation - """ - self._response_routers.append(router) - - async def __aenter__(self) -> Self: - self._task_group = anyio.create_task_group() - await self._task_group.__aenter__() - self._task_group.start_soon(self._receive_loop) - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> bool | None: - await self._exit_stack.aclose() - # Using BaseSession as a context manager should not block on exit (this - # would be very surprising behavior), so make sure to cancel the tasks - # in the task group. - self._task_group.cancel_scope.cancel() - return await self._task_group.__aexit__(exc_type, exc_val, exc_tb) - - async def send_request( - self, - request: SendRequestT, - result_type: type[ReceiveResultT], - request_read_timeout_seconds: timedelta | None = None, - metadata: MessageMetadata = None, - progress_callback: ProgressFnT | None = None, - ) -> ReceiveResultT: - """ - Sends a request and wait for a response. Raises an McpError if the - response contains an error. If a request read timeout is provided, it - will take precedence over the session read timeout. - - Do not use this method to emit notifications! Use send_notification() - instead. - """ - request_id = self._request_id - self._request_id = request_id + 1 - - response_stream, response_stream_reader = anyio.create_memory_object_stream[JSONRPCResponse | JSONRPCError](1) - self._response_streams[request_id] = response_stream - - # Set up progress token if progress callback is provided - request_data = request.model_dump(by_alias=True, mode="json", exclude_none=True) - if progress_callback is not None: # pragma: no cover - # Use request_id as progress token - if "params" not in request_data: - request_data["params"] = {} - if "_meta" not in request_data["params"]: # pragma: no branch - request_data["params"]["_meta"] = {} - request_data["params"]["_meta"]["progressToken"] = request_id - # Store the callback for this request - self._progress_callbacks[request_id] = progress_callback - - try: - jsonrpc_request = JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - **request_data, - ) - - await self._write_stream.send(SessionMessage(message=JSONRPCMessage(jsonrpc_request), metadata=metadata)) - - # request read timeout takes precedence over session read timeout - timeout = None - if request_read_timeout_seconds is not None: # pragma: no cover - timeout = request_read_timeout_seconds.total_seconds() - elif self._session_read_timeout_seconds is not None: # pragma: no cover - timeout = self._session_read_timeout_seconds.total_seconds() - - try: - with anyio.fail_after(timeout): - response_or_error = await response_stream_reader.receive() - except TimeoutError: - raise McpError( - ErrorData( - code=httpx.codes.REQUEST_TIMEOUT, - message=( - f"Timed out while waiting for response to " - f"{request.__class__.__name__}. Waited " - f"{timeout} seconds." - ), - ) - ) - - if isinstance(response_or_error, JSONRPCError): - raise McpError(response_or_error.error) - else: - return result_type.model_validate(response_or_error.result) - - finally: - self._response_streams.pop(request_id, None) - self._progress_callbacks.pop(request_id, None) - await response_stream.aclose() - await response_stream_reader.aclose() - - async def send_notification( - self, - notification: SendNotificationT, - related_request_id: RequestId | None = None, - ) -> None: - """ - Emits a notification, which is a one-way message that does not expect - a response. - """ - # Some transport implementations may need to set the related_request_id - # to attribute to the notifications to the request that triggered them. - jsonrpc_notification = JSONRPCNotification( - jsonrpc="2.0", - **notification.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - session_message = SessionMessage( # pragma: no cover - message=JSONRPCMessage(jsonrpc_notification), - metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None, - ) - await self._write_stream.send(session_message) - - async def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None: - if isinstance(response, ErrorData): - jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response) - session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_error)) - await self._write_stream.send(session_message) - else: - jsonrpc_response = JSONRPCResponse( - jsonrpc="2.0", - id=request_id, - result=response.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_response)) - await self._write_stream.send(session_message) - - async def _receive_loop(self) -> None: - async with ( - self._read_stream, - self._write_stream, - ): - try: - async for message in self._read_stream: - if isinstance(message, Exception): # pragma: no cover - await self._handle_incoming(message) - elif isinstance(message.message.root, JSONRPCRequest): - try: - validated_request = self._receive_request_type.model_validate( - message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) - ) - responder = RequestResponder( - request_id=message.message.root.id, - request_meta=validated_request.root.params.meta - if validated_request.root.params - else None, - request=validated_request, - session=self, - on_complete=lambda r: self._in_flight.pop(r.request_id, None), - message_metadata=message.metadata, - ) - self._in_flight[responder.request_id] = responder - await self._received_request(responder) - - if not responder._completed: # type: ignore[reportPrivateUsage] - await self._handle_incoming(responder) - except Exception as e: - # For request validation errors, send a proper JSON-RPC error - # response instead of crashing the server - logging.warning(f"Failed to validate request: {e}") - logging.debug(f"Message that failed validation: {message.message.root}") - error_response = JSONRPCError( - jsonrpc="2.0", - id=message.message.root.id, - error=ErrorData( - code=INVALID_PARAMS, - message="Invalid request parameters", - data="", - ), - ) - session_message = SessionMessage(message=JSONRPCMessage(error_response)) - await self._write_stream.send(session_message) - - elif isinstance(message.message.root, JSONRPCNotification): - try: - notification = self._receive_notification_type.model_validate( - message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) - ) - # Handle cancellation notifications - if isinstance(notification.root, CancelledNotification): - cancelled_id = notification.root.params.requestId - if cancelled_id in self._in_flight: # pragma: no branch - await self._in_flight[cancelled_id].cancel() - else: - # Handle progress notifications callback - if isinstance(notification.root, ProgressNotification): # pragma: no cover - progress_token = notification.root.params.progressToken - # If there is a progress callback for this token, - # call it with the progress information - if progress_token in self._progress_callbacks: - callback = self._progress_callbacks[progress_token] - try: - await callback( - notification.root.params.progress, - notification.root.params.total, - notification.root.params.message, - ) - except Exception as e: - logging.error( - "Progress callback raised an exception: %s", - e, - ) - await self._received_notification(notification) - await self._handle_incoming(notification) - except Exception as e: # pragma: no cover - # For other validation errors, log and continue - logging.warning( - f"Failed to validate notification: {e}. Message was: {message.message.root}" - ) - else: # Response or error - await self._handle_response(message) - - except anyio.ClosedResourceError: - # This is expected when the client disconnects abruptly. - # Without this handler, the exception would propagate up and - # crash the server's task group. - logging.debug("Read stream closed by client") # pragma: no cover - except Exception as e: # pragma: no cover - # Other exceptions are not expected and should be logged. We purposefully - # catch all exceptions here to avoid crashing the server. - logging.exception(f"Unhandled exception in receive loop: {e}") - finally: - # after the read stream is closed, we need to send errors - # to any pending requests - # Snapshot: stream.send() wakes the waiter, whose finally pops - # from _response_streams before the next __next__() call. - for id, stream in list(self._response_streams.items()): - error = ErrorData(code=CONNECTION_CLOSED, message="Connection closed") - try: - await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error)) - await stream.aclose() - except Exception: # pragma: no cover - # Stream might already be closed - pass - self._response_streams.clear() - - def _normalize_request_id(self, response_id: RequestId) -> RequestId: - """ - Normalize a response ID to match how request IDs are stored. - - Since the client always sends integer IDs, we normalize string IDs - to integers when possible. This matches the TypeScript SDK approach: - https://github.com/modelcontextprotocol/typescript-sdk/blob/a606fb17909ea454e83aab14c73f14ea45c04448/src/shared/protocol.ts#L861 - - Args: - response_id: The response ID from the incoming message. - - Returns: - The normalized ID (int if possible, otherwise original value). - """ - if isinstance(response_id, str): - try: - return int(response_id) - except ValueError: - logging.warning(f"Response ID {response_id!r} cannot be normalized to match pending requests") - return response_id - - async def _handle_response(self, message: SessionMessage) -> None: - """ - Handle an incoming response or error message. - - Checks response routers first (e.g., for task-related responses), - then falls back to the normal response stream mechanism. - """ - root = message.message.root - - # This check is always true at runtime: the caller (_receive_loop) only invokes - # this method in the else branch after checking for JSONRPCRequest and - # JSONRPCNotification. However, the type checker can't infer this from the - # method signature, so we need this guard for type narrowing. - if not isinstance(root, JSONRPCResponse | JSONRPCError): - return # pragma: no cover - - # Normalize response ID to handle type mismatches (e.g., "0" vs 0) - response_id = self._normalize_request_id(root.id) - - # First, check response routers (e.g., TaskResultHandler) - if isinstance(root, JSONRPCError): - # Route error to routers - for router in self._response_routers: - if router.route_error(response_id, root.error): - return # Handled - else: - # Route success response to routers - response_data: dict[str, Any] = root.result or {} - for router in self._response_routers: - if router.route_response(response_id, response_data): - return # Handled - - # Fall back to normal response streams - stream = self._response_streams.pop(response_id, None) - if stream: # pragma: no cover - await stream.send(root) - else: # pragma: no cover - await self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}")) - - async def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None: - """ - Can be overridden by subclasses to handle a request without needing to - listen on the message stream. - - If the request is responded to within this method, it will not be - forwarded on to the message stream. - """ - - async def _received_notification(self, notification: ReceiveNotificationT) -> None: - """ - Can be overridden by subclasses to handle a notification without needing - to listen on the message stream. - """ - - async def send_progress_notification( - self, - progress_token: str | int, - progress: float, - total: float | None = None, - message: str | None = None, - ) -> None: - """ - Sends a progress notification for a request that is currently being - processed. - """ - - async def _handle_incoming( - self, - req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception, - ) -> None: - """A generic handler for incoming messages. Overwritten by subclasses.""" - pass # pragma: no cover diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/tool_name_validation.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/tool_name_validation.py deleted file mode 100644 index f35efa5..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/tool_name_validation.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tool name validation utilities according to SEP-986. - -Tool names SHOULD be between 1 and 128 characters in length (inclusive). -Tool names are case-sensitive. -Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), -digits (0-9), underscore (_), dash (-), and dot (.). -Tool names SHOULD NOT contain spaces, commas, or other special characters. - -See: https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names -""" - -from __future__ import annotations - -import logging -import re -from dataclasses import dataclass, field - -logger = logging.getLogger(__name__) - -# Regular expression for valid tool names according to SEP-986 specification -TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}$") - -# SEP reference URL for warning messages -SEP_986_URL = "https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names" - - -@dataclass -class ToolNameValidationResult: - """Result of tool name validation. - - Attributes: - is_valid: Whether the tool name conforms to SEP-986 requirements. - warnings: List of warning messages for non-conforming aspects. - """ - - is_valid: bool - warnings: list[str] = field(default_factory=lambda: []) - - -def validate_tool_name(name: str) -> ToolNameValidationResult: - """Validate a tool name according to the SEP-986 specification. - - Args: - name: The tool name to validate. - - Returns: - ToolNameValidationResult containing validation status and any warnings. - """ - warnings: list[str] = [] - - # Check for empty name - if not name: - return ToolNameValidationResult( - is_valid=False, - warnings=["Tool name cannot be empty"], - ) - - # Check length - if len(name) > 128: - return ToolNameValidationResult( - is_valid=False, - warnings=[f"Tool name exceeds maximum length of 128 characters (current: {len(name)})"], - ) - - # Check for problematic patterns (warnings, not validation failures) - if " " in name: - warnings.append("Tool name contains spaces, which may cause parsing issues") - - if "," in name: - warnings.append("Tool name contains commas, which may cause parsing issues") - - # Check for potentially confusing leading/trailing characters - if name.startswith("-") or name.endswith("-"): - warnings.append("Tool name starts or ends with a dash, which may cause parsing issues in some contexts") - - if name.startswith(".") or name.endswith("."): - warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts") - - # Check for invalid characters - if not TOOL_NAME_REGEX.match(name): - # Find all invalid characters (unique, preserving order) - invalid_chars: list[str] = [] - seen: set[str] = set() - for char in name: - if not re.match(r"[A-Za-z0-9._-]", char) and char not in seen: - invalid_chars.append(char) - seen.add(char) - - warnings.append(f"Tool name contains invalid characters: {', '.join(repr(c) for c in invalid_chars)}") - warnings.append("Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)") - - return ToolNameValidationResult(is_valid=False, warnings=warnings) - - return ToolNameValidationResult(is_valid=True, warnings=warnings) - - -def issue_tool_name_warning(name: str, warnings: list[str]) -> None: - """Log warnings for non-conforming tool names. - - Args: - name: The tool name that triggered the warnings. - warnings: List of warning messages to log. - """ - if not warnings: - return - - logger.warning(f'Tool name validation warning for "{name}":') - for warning in warnings: - logger.warning(f" - {warning}") - logger.warning("Tool registration will proceed, but this may cause compatibility issues.") - logger.warning("Consider updating the tool name to conform to the MCP tool naming standard.") - logger.warning(f"See SEP-986 ({SEP_986_URL}) for more details.") - - -def validate_and_warn_tool_name(name: str) -> bool: - """Validate a tool name and issue warnings for non-conforming names. - - This is the primary entry point for tool name validation. It validates - the name and logs any warnings via the logging module. - - Args: - name: The tool name to validate. - - Returns: - True if the name is valid, False otherwise. - """ - result = validate_tool_name(name) - issue_tool_name_warning(name, result.warnings) - return result.is_valid diff --git a/src/agent_workflow/mcp/sdk/src/mcp/shared/version.py b/src/agent_workflow/mcp/sdk/src/mcp/shared/version.py deleted file mode 100644 index d2a1e46..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/shared/version.py +++ /dev/null @@ -1,3 +0,0 @@ -from mcp.types import LATEST_PROTOCOL_VERSION - -SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] diff --git a/src/agent_workflow/mcp/sdk/src/mcp/types.py b/src/agent_workflow/mcp/sdk/src/mcp/types.py deleted file mode 100644 index 654c006..0000000 --- a/src/agent_workflow/mcp/sdk/src/mcp/types.py +++ /dev/null @@ -1,1999 +0,0 @@ -from collections.abc import Callable -from datetime import datetime -from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar - -from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel -from pydantic.networks import AnyUrl, UrlConstraints -from typing_extensions import deprecated - -""" -Model Context Protocol bindings for Python - -These bindings were generated from https://github.com/modelcontextprotocol/specification, -using Claude, with a prompt something like the following: - -Generate idiomatic Python bindings for this schema for MCP, or the "Model Context -Protocol." The schema is defined in TypeScript, but there's also a JSON Schema version -for reference. - -* For the bindings, let's use Pydantic V2 models. -* Each model should allow extra fields everywhere, by specifying `model_config = - ConfigDict(extra='allow')`. Do this in every case, instead of a custom base class. -* Union types should be represented with a Pydantic `RootModel`. -* Define additional model classes instead of using dictionaries. Do this even if they're - not separate types in the schema. -""" - -LATEST_PROTOCOL_VERSION = "2025-11-25" - -""" -The default negotiated version of the Model Context Protocol when no version is specified. -We need this to satisfy the MCP specification, which requires the server to assume a -specific version if none is provided by the client. See section "Protocol Version Header" at -https://modelcontextprotocol.io/specification -""" -DEFAULT_NEGOTIATED_VERSION = "2025-03-26" - -ProgressToken = str | int -Cursor = str -Role = Literal["user", "assistant"] -RequestId = Annotated[int, Field(strict=True)] | str -AnyFunction: TypeAlias = Callable[..., Any] - -TaskExecutionMode = Literal["forbidden", "optional", "required"] -TASK_FORBIDDEN: Final[Literal["forbidden"]] = "forbidden" -TASK_OPTIONAL: Final[Literal["optional"]] = "optional" -TASK_REQUIRED: Final[Literal["required"]] = "required" - - -class TaskMetadata(BaseModel): - """ - Metadata for augmenting a request with task execution. - Include this in the `task` field of the request parameters. - """ - - model_config = ConfigDict(extra="allow") - - ttl: Annotated[int, Field(strict=True)] | None = None - """Requested duration in milliseconds to retain task from creation.""" - - -class RequestParams(BaseModel): - class Meta(BaseModel): - progressToken: ProgressToken | None = None - """ - If specified, the caller requests out-of-band progress notifications for - this request (as represented by notifications/progress). The value of this - parameter is an opaque token that will be attached to any subsequent - notifications. The receiver is not obligated to provide these notifications. - """ - - model_config = ConfigDict(extra="allow") - - task: TaskMetadata | None = None - """ - If specified, the caller is requesting task-augmented execution for this request. - The request will return a CreateTaskResult immediately, and the actual result can be - retrieved later via tasks/result. - - Task augmentation is subject to capability negotiation - receivers MUST declare support - for task augmentation of specific request types in their capabilities. - """ - - meta: Meta | None = Field(alias="_meta", default=None) - - -class PaginatedRequestParams(RequestParams): - cursor: Cursor | None = None - """ - An opaque token representing the current pagination position. - If provided, the server should return results starting after this cursor. - """ - - -class NotificationParams(BaseModel): - class Meta(BaseModel): - model_config = ConfigDict(extra="allow") - - meta: Meta | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - - -RequestParamsT = TypeVar("RequestParamsT", bound=RequestParams | dict[str, Any] | None) -NotificationParamsT = TypeVar("NotificationParamsT", bound=NotificationParams | dict[str, Any] | None) -MethodT = TypeVar("MethodT", bound=str) - - -class Request(BaseModel, Generic[RequestParamsT, MethodT]): - """Base class for JSON-RPC requests.""" - - method: MethodT - params: RequestParamsT - model_config = ConfigDict(extra="allow") - - -class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]): - """Base class for paginated requests, - matching the schema's PaginatedRequest interface.""" - - params: PaginatedRequestParams | None = None - - -class Notification(BaseModel, Generic[NotificationParamsT, MethodT]): - """Base class for JSON-RPC notifications.""" - - method: MethodT - params: NotificationParamsT - model_config = ConfigDict(extra="allow") - - -class Result(BaseModel): - """Base class for JSON-RPC results.""" - - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class PaginatedResult(Result): - nextCursor: Cursor | None = None - """ - An opaque token representing the pagination position after the last returned result. - If present, there may be more results available. - """ - - -class JSONRPCRequest(Request[dict[str, Any] | None, str]): - """A request that expects a response.""" - - jsonrpc: Literal["2.0"] - id: RequestId - method: str - params: dict[str, Any] | None = None - - -class JSONRPCNotification(Notification[dict[str, Any] | None, str]): - """A notification which does not expect a response.""" - - jsonrpc: Literal["2.0"] - params: dict[str, Any] | None = None - - -class JSONRPCResponse(BaseModel): - """A successful (non-error) response to a request.""" - - jsonrpc: Literal["2.0"] - id: RequestId - result: dict[str, Any] - model_config = ConfigDict(extra="allow") - - -# MCP-specific error codes in the range [-32000, -32099] -URL_ELICITATION_REQUIRED = -32042 -"""Error code indicating that a URL mode elicitation is required before the request can be processed.""" - -# SDK error codes -CONNECTION_CLOSED = -32000 -# REQUEST_TIMEOUT = -32001 # the typescript sdk uses this - -# Standard JSON-RPC error codes -PARSE_ERROR = -32700 -INVALID_REQUEST = -32600 -METHOD_NOT_FOUND = -32601 -INVALID_PARAMS = -32602 -INTERNAL_ERROR = -32603 - - -class ErrorData(BaseModel): - """Error information for JSON-RPC error responses.""" - - code: int - """The error type that occurred.""" - - message: str - """ - A short description of the error. The message SHOULD be limited to a concise single - sentence. - """ - - data: Any | None = None - """ - Additional information about the error. The value of this member is defined by the - sender (e.g. detailed error information, nested errors etc.). - """ - - model_config = ConfigDict(extra="allow") - - -class JSONRPCError(BaseModel): - """A response to a request that indicates an error occurred.""" - - jsonrpc: Literal["2.0"] - id: str | int - error: ErrorData - model_config = ConfigDict(extra="allow") - - -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]): - pass - - -class EmptyResult(Result): - """A response that indicates success but carries no data.""" - - -class BaseMetadata(BaseModel): - """Base class for entities with name and optional title fields.""" - - name: str - """The programmatic name of the entity.""" - - title: str | None = None - """ - Intended for UI and end-user contexts — optimized to be human-readable and easily understood, - even by those unfamiliar with domain-specific terminology. - - If not provided, the name should be used for display (except for Tool, - where `annotations.title` should be given precedence over using `name`, - if present). - """ - - -class Icon(BaseModel): - """An icon for display in user interfaces.""" - - src: str - """URL or data URI for the icon.""" - - mimeType: str | None = None - """Optional MIME type for the icon.""" - - sizes: list[str] | None = None - """Optional list of strings specifying icon dimensions (e.g., ["48x48", "96x96"]).""" - - model_config = ConfigDict(extra="allow") - - -class Implementation(BaseMetadata): - """Describes the name and version of an MCP implementation.""" - - version: str - - websiteUrl: str | None = None - """An optional URL of the website for this implementation.""" - - icons: list[Icon] | None = None - """An optional list of icons for this implementation.""" - - model_config = ConfigDict(extra="allow") - - -class RootsCapability(BaseModel): - """Capability for root operations.""" - - listChanged: bool | None = None - """Whether the client supports notifications for changes to the roots list.""" - model_config = ConfigDict(extra="allow") - - -class SamplingContextCapability(BaseModel): - """ - Capability for context inclusion during sampling. - - Indicates support for non-'none' values in the includeContext parameter. - SOFT-DEPRECATED: New implementations should use tools parameter instead. - """ - - model_config = ConfigDict(extra="allow") - - -class SamplingToolsCapability(BaseModel): - """ - Capability indicating support for tool calling during sampling. - - When present in ClientCapabilities.sampling, indicates that the client - supports the tools and toolChoice parameters in sampling requests. - """ - - model_config = ConfigDict(extra="allow") - - -class FormElicitationCapability(BaseModel): - """Capability for form mode elicitation.""" - - model_config = ConfigDict(extra="allow") - - -class UrlElicitationCapability(BaseModel): - """Capability for URL mode elicitation.""" - - model_config = ConfigDict(extra="allow") - - -class ElicitationCapability(BaseModel): - """Capability for elicitation operations. - - Clients must support at least one mode (form or url). - """ - - form: FormElicitationCapability | None = None - """Present if the client supports form mode elicitation.""" - - url: UrlElicitationCapability | None = None - """Present if the client supports URL mode elicitation.""" - - model_config = ConfigDict(extra="allow") - - -class SamplingCapability(BaseModel): - """ - Sampling capability structure, allowing fine-grained capability advertisement. - """ - - context: SamplingContextCapability | None = None - """ - Present if the client supports non-'none' values for includeContext parameter. - SOFT-DEPRECATED: New implementations should use tools parameter instead. - """ - tools: SamplingToolsCapability | None = None - """ - Present if the client supports tools and toolChoice parameters in sampling requests. - Presence indicates full tool calling support during sampling. - """ - model_config = ConfigDict(extra="allow") - - -class TasksListCapability(BaseModel): - """Capability for tasks listing operations.""" - - model_config = ConfigDict(extra="allow") - - -class TasksCancelCapability(BaseModel): - """Capability for tasks cancel operations.""" - - model_config = ConfigDict(extra="allow") - - -class TasksCreateMessageCapability(BaseModel): - """Capability for tasks create messages.""" - - model_config = ConfigDict(extra="allow") - - -class TasksSamplingCapability(BaseModel): - """Capability for tasks sampling operations.""" - - model_config = ConfigDict(extra="allow") - - createMessage: TasksCreateMessageCapability | None = None - - -class TasksCreateElicitationCapability(BaseModel): - """Capability for tasks create elicitation operations.""" - - model_config = ConfigDict(extra="allow") - - -class TasksElicitationCapability(BaseModel): - """Capability for tasks elicitation operations.""" - - model_config = ConfigDict(extra="allow") - - create: TasksCreateElicitationCapability | None = None - - -class ClientTasksRequestsCapability(BaseModel): - """Capability for tasks requests operations.""" - - model_config = ConfigDict(extra="allow") - - sampling: TasksSamplingCapability | None = None - - elicitation: TasksElicitationCapability | None = None - - -class ClientTasksCapability(BaseModel): - """Capability for client tasks operations.""" - - model_config = ConfigDict(extra="allow") - - list: TasksListCapability | None = None - """Whether this client supports tasks/list.""" - - cancel: TasksCancelCapability | None = None - """Whether this client supports tasks/cancel.""" - - requests: ClientTasksRequestsCapability | None = None - """Specifies which request types can be augmented with tasks.""" - - -class ClientCapabilities(BaseModel): - """Capabilities a client may support.""" - - experimental: dict[str, dict[str, Any]] | None = None - """Experimental, non-standard capabilities that the client supports.""" - sampling: SamplingCapability | None = None - """ - Present if the client supports sampling from an LLM. - Can contain fine-grained capabilities like context and tools support. - """ - elicitation: ElicitationCapability | None = None - """Present if the client supports elicitation from the user.""" - roots: RootsCapability | None = None - """Present if the client supports listing roots.""" - tasks: ClientTasksCapability | None = None - """Present if the client supports task-augmented requests.""" - - model_config = ConfigDict(extra="allow") - - -class PromptsCapability(BaseModel): - """Capability for prompts operations.""" - - listChanged: bool | None = None - """Whether this server supports notifications for changes to the prompt list.""" - model_config = ConfigDict(extra="allow") - - -class ResourcesCapability(BaseModel): - """Capability for resources operations.""" - - subscribe: bool | None = None - """Whether this server supports subscribing to resource updates.""" - listChanged: bool | None = None - """Whether this server supports notifications for changes to the resource list.""" - model_config = ConfigDict(extra="allow") - - -class ToolsCapability(BaseModel): - """Capability for tools operations.""" - - listChanged: bool | None = None - """Whether this server supports notifications for changes to the tool list.""" - model_config = ConfigDict(extra="allow") - - -class LoggingCapability(BaseModel): - """Capability for logging operations.""" - - model_config = ConfigDict(extra="allow") - - -class CompletionsCapability(BaseModel): - """Capability for completions operations.""" - - model_config = ConfigDict(extra="allow") - - -class TasksCallCapability(BaseModel): - """Capability for tasks call operations.""" - - model_config = ConfigDict(extra="allow") - - -class TasksToolsCapability(BaseModel): - """Capability for tasks tools operations.""" - - model_config = ConfigDict(extra="allow") - call: TasksCallCapability | None = None - - -class ServerTasksRequestsCapability(BaseModel): - """Capability for tasks requests operations.""" - - model_config = ConfigDict(extra="allow") - - tools: TasksToolsCapability | None = None - - -class ServerTasksCapability(BaseModel): - """Capability for server tasks operations.""" - - model_config = ConfigDict(extra="allow") - - list: TasksListCapability | None = None - cancel: TasksCancelCapability | None = None - requests: ServerTasksRequestsCapability | None = None - - -class ServerCapabilities(BaseModel): - """Capabilities that a server may support.""" - - experimental: dict[str, dict[str, Any]] | None = None - """Experimental, non-standard capabilities that the server supports.""" - logging: LoggingCapability | None = None - """Present if the server supports sending log messages to the client.""" - prompts: PromptsCapability | None = None - """Present if the server offers any prompt templates.""" - resources: ResourcesCapability | None = None - """Present if the server offers any resources to read.""" - tools: ToolsCapability | None = None - """Present if the server offers any tools to call.""" - completions: CompletionsCapability | None = None - """Present if the server offers autocompletion suggestions for prompts and resources.""" - tasks: ServerTasksCapability | None = None - """Present if the server supports task-augmented requests.""" - model_config = ConfigDict(extra="allow") - - -TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] - -# Task status constants -TASK_STATUS_WORKING: Final[Literal["working"]] = "working" -TASK_STATUS_INPUT_REQUIRED: Final[Literal["input_required"]] = "input_required" -TASK_STATUS_COMPLETED: Final[Literal["completed"]] = "completed" -TASK_STATUS_FAILED: Final[Literal["failed"]] = "failed" -TASK_STATUS_CANCELLED: Final[Literal["cancelled"]] = "cancelled" - - -class RelatedTaskMetadata(BaseModel): - """ - Metadata for associating messages with a task. - - Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. - """ - - model_config = ConfigDict(extra="allow") - taskId: str - """The task identifier this message is associated with.""" - - -class Task(BaseModel): - """Data associated with a task.""" - - model_config = ConfigDict(extra="allow") - - taskId: str - """The task identifier.""" - - status: TaskStatus - """Current task state.""" - - statusMessage: str | None = None - """ - Optional human-readable message describing the current task state. - This can provide context for any status, including: - - Reasons for "cancelled" status - - Summaries for "completed" status - - Diagnostic information for "failed" status (e.g., error details, what went wrong) - """ - - createdAt: datetime # Pydantic will enforce ISO 8601 and re-serialize as a string later - """ISO 8601 timestamp when the task was created.""" - - lastUpdatedAt: datetime - """ISO 8601 timestamp when the task was last updated.""" - - ttl: Annotated[int, Field(strict=True)] | None - """Actual retention duration from creation in milliseconds, null for unlimited.""" - - pollInterval: Annotated[int, Field(strict=True)] | None = None - """Suggested polling interval in milliseconds.""" - - -class CreateTaskResult(Result): - """A response to a task-augmented request.""" - - task: Task - - -class GetTaskRequestParams(RequestParams): - model_config = ConfigDict(extra="allow") - taskId: str - """The task identifier to query.""" - - -class GetTaskRequest(Request[GetTaskRequestParams, Literal["tasks/get"]]): - """A request to retrieve the state of a task.""" - - method: Literal["tasks/get"] = "tasks/get" - - params: GetTaskRequestParams - - -class GetTaskResult(Result, Task): - """The response to a tasks/get request.""" - - -class GetTaskPayloadRequestParams(RequestParams): - model_config = ConfigDict(extra="allow") - - taskId: str - """The task identifier to retrieve results for.""" - - -class GetTaskPayloadRequest(Request[GetTaskPayloadRequestParams, Literal["tasks/result"]]): - """A request to retrieve the result of a completed task.""" - - method: Literal["tasks/result"] = "tasks/result" - params: GetTaskPayloadRequestParams - - -class GetTaskPayloadResult(Result): - """ - The response to a tasks/result request. - The structure matches the result type of the original request. - For example, a tools/call task would return the CallToolResult structure. - """ - - -class CancelTaskRequestParams(RequestParams): - model_config = ConfigDict(extra="allow") - - taskId: str - """The task identifier to cancel.""" - - -class CancelTaskRequest(Request[CancelTaskRequestParams, Literal["tasks/cancel"]]): - """A request to cancel a task.""" - - method: Literal["tasks/cancel"] = "tasks/cancel" - params: CancelTaskRequestParams - - -class CancelTaskResult(Result, Task): - """The response to a tasks/cancel request.""" - - -class ListTasksRequest(PaginatedRequest[Literal["tasks/list"]]): - """A request to retrieve a list of tasks.""" - - method: Literal["tasks/list"] = "tasks/list" - - -class ListTasksResult(PaginatedResult): - """The response to a tasks/list request.""" - - tasks: list[Task] - - -class TaskStatusNotificationParams(NotificationParams, Task): - """Parameters for a `notifications/tasks/status` notification.""" - - -class TaskStatusNotification(Notification[TaskStatusNotificationParams, Literal["notifications/tasks/status"]]): - """ - An optional notification from the receiver to the requestor, informing them that a task's status has changed. - Receivers are not required to send these notifications - """ - - method: Literal["notifications/tasks/status"] = "notifications/tasks/status" - params: TaskStatusNotificationParams - - -class InitializeRequestParams(RequestParams): - """Parameters for the initialize request.""" - - protocolVersion: str | int - """The latest version of the Model Context Protocol that the client supports.""" - capabilities: ClientCapabilities - clientInfo: Implementation - model_config = ConfigDict(extra="allow") - - -class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]): - """ - This request is sent from the client to the server when it first connects, asking it - to begin initialization. - """ - - method: Literal["initialize"] = "initialize" - params: InitializeRequestParams - - -class InitializeResult(Result): - """After receiving an initialize request from the client, the server sends this.""" - - protocolVersion: str | int - """The version of the Model Context Protocol that the server wants to use.""" - capabilities: ServerCapabilities - serverInfo: Implementation - instructions: str | None = None - """Instructions describing how to use the server and its features.""" - - -class InitializedNotification(Notification[NotificationParams | None, Literal["notifications/initialized"]]): - """ - This notification is sent from the client to the server after initialization has - finished. - """ - - method: Literal["notifications/initialized"] = "notifications/initialized" - params: NotificationParams | None = None - - -class PingRequest(Request[RequestParams | None, Literal["ping"]]): - """ - A ping, issued by either the server or the client, to check that the other party is - still alive. - """ - - method: Literal["ping"] = "ping" - params: RequestParams | None = None - - -class ProgressNotificationParams(NotificationParams): - """Parameters for progress notifications.""" - - progressToken: ProgressToken - """ - The progress token which was given in the initial request, used to associate this - notification with the request that is proceeding. - """ - progress: float - """ - The progress thus far. This should increase every time progress is made, even if the - total is unknown. - """ - total: float | None = None - """Total number of items to process (or total progress required), if known.""" - message: str | None = None - """ - Message related to progress. This should provide relevant human readable - progress information. - """ - model_config = ConfigDict(extra="allow") - - -class ProgressNotification(Notification[ProgressNotificationParams, Literal["notifications/progress"]]): - """ - An out-of-band notification used to inform the receiver of a progress update for a - long-running request. - """ - - method: Literal["notifications/progress"] = "notifications/progress" - params: ProgressNotificationParams - - -class ListResourcesRequest(PaginatedRequest[Literal["resources/list"]]): - """Sent from the client to request a list of resources the server has.""" - - method: Literal["resources/list"] = "resources/list" - - -class Annotations(BaseModel): - audience: list[Role] | None = None - priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None - model_config = ConfigDict(extra="allow") - - -class Resource(BaseMetadata): - """A known resource that the server is capable of reading.""" - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] - """The URI of this resource.""" - description: str | None = None - """A description of what this resource represents.""" - mimeType: str | None = None - """The MIME type of this resource, if known.""" - size: int | None = None - """ - The size of the raw resource content, in bytes (i.e., before base64 encoding - or any tokenization), if known. - - This can be used by Hosts to display file sizes and estimate context window usage. - """ - icons: list[Icon] | None = None - """An optional list of icons for this resource.""" - annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ResourceTemplate(BaseMetadata): - """A template description for resources available on the server.""" - - uriTemplate: str - """ - A URI template (according to RFC 6570) that can be used to construct resource - URIs. - """ - description: str | None = None - """A human-readable description of what this template is for.""" - mimeType: str | None = None - """ - The MIME type for all resources that match this template. This should only be - included if all resources matching this template have the same type. - """ - icons: list[Icon] | None = None - """An optional list of icons for this resource template.""" - annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ListResourcesResult(PaginatedResult): - """The server's response to a resources/list request from the client.""" - - resources: list[Resource] - - -class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates/list"]]): - """Sent from the client to request a list of resource templates the server has.""" - - method: Literal["resources/templates/list"] = "resources/templates/list" - - -class ListResourceTemplatesResult(PaginatedResult): - """The server's response to a resources/templates/list request from the client.""" - - resourceTemplates: list[ResourceTemplate] - - -class ReadResourceRequestParams(RequestParams): - """Parameters for reading a resource.""" - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] - """ - The URI of the resource to read. The URI can use any protocol; it is up to the - server how to interpret it. - """ - model_config = ConfigDict(extra="allow") - - -class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): - """Sent from the client to the server, to read a specific resource URI.""" - - method: Literal["resources/read"] = "resources/read" - params: ReadResourceRequestParams - - -class ResourceContents(BaseModel): - """The contents of a specific resource or sub-resource.""" - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] - """The URI of this resource.""" - mimeType: str | None = None - """The MIME type of this resource, if known.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class TextResourceContents(ResourceContents): - """Text contents of a resource.""" - - text: str - """ - The text of the item. This must only be set if the item can actually be represented - as text (not binary data). - """ - - -class BlobResourceContents(ResourceContents): - """Binary contents of a resource.""" - - blob: str - """A base64-encoded string representing the binary data of the item.""" - - -class ReadResourceResult(Result): - """The server's response to a resources/read request from the client.""" - - contents: list[TextResourceContents | BlobResourceContents] - - -class ResourceListChangedNotification( - Notification[NotificationParams | None, Literal["notifications/resources/list_changed"]] -): - """ - An optional notification from the server to the client, informing it that the list - of resources it can read from has changed. - """ - - method: Literal["notifications/resources/list_changed"] = "notifications/resources/list_changed" - params: NotificationParams | None = None - - -class SubscribeRequestParams(RequestParams): - """Parameters for subscribing to a resource.""" - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] - """ - The URI of the resource to subscribe to. The URI can use any protocol; it is up to - the server how to interpret it. - """ - model_config = ConfigDict(extra="allow") - - -class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscribe"]]): - """ - Sent from the client to request resources/updated notifications from the server - whenever a particular resource changes. - """ - - method: Literal["resources/subscribe"] = "resources/subscribe" - params: SubscribeRequestParams - - -class UnsubscribeRequestParams(RequestParams): - """Parameters for unsubscribing from a resource.""" - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] - """The URI of the resource to unsubscribe from.""" - model_config = ConfigDict(extra="allow") - - -class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]]): - """ - Sent from the client to request cancellation of resources/updated notifications from - the server. - """ - - method: Literal["resources/unsubscribe"] = "resources/unsubscribe" - params: UnsubscribeRequestParams - - -class ResourceUpdatedNotificationParams(NotificationParams): - """Parameters for resource update notifications.""" - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] - """ - The URI of the resource that has been updated. This might be a sub-resource of the - one that the client actually subscribed to. - """ - model_config = ConfigDict(extra="allow") - - -class ResourceUpdatedNotification( - Notification[ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"]] -): - """ - A notification from the server to the client, informing it that a resource has - changed and may need to be read again. - """ - - method: Literal["notifications/resources/updated"] = "notifications/resources/updated" - params: ResourceUpdatedNotificationParams - - -class ListPromptsRequest(PaginatedRequest[Literal["prompts/list"]]): - """Sent from the client to request a list of prompts and prompt templates.""" - - method: Literal["prompts/list"] = "prompts/list" - - -class PromptArgument(BaseModel): - """An argument for a prompt template.""" - - name: str - """The name of the argument.""" - description: str | None = None - """A human-readable description of the argument.""" - required: bool | None = None - """Whether this argument must be provided.""" - model_config = ConfigDict(extra="allow") - - -class Prompt(BaseMetadata): - """A prompt or prompt template that the server offers.""" - - description: str | None = None - """An optional description of what this prompt provides.""" - arguments: list[PromptArgument] | None = None - """A list of arguments to use for templating the prompt.""" - icons: list[Icon] | None = None - """An optional list of icons for this prompt.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ListPromptsResult(PaginatedResult): - """The server's response to a prompts/list request from the client.""" - - prompts: list[Prompt] - - -class GetPromptRequestParams(RequestParams): - """Parameters for getting a prompt.""" - - name: str - """The name of the prompt or prompt template.""" - arguments: dict[str, str] | None = None - """Arguments to use for templating the prompt.""" - model_config = ConfigDict(extra="allow") - - -class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): - """Used by the client to get a prompt provided by the server.""" - - method: Literal["prompts/get"] = "prompts/get" - params: GetPromptRequestParams - - -class TextContent(BaseModel): - """Text content for a message.""" - - type: Literal["text"] - text: str - """The text content of the message.""" - annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ImageContent(BaseModel): - """Image content for a message.""" - - type: Literal["image"] - data: str - """The base64-encoded image data.""" - mimeType: str - """ - The MIME type of the image. Different providers may support different - image types. - """ - annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class AudioContent(BaseModel): - """Audio content for a message.""" - - type: Literal["audio"] - data: str - """The base64-encoded audio data.""" - mimeType: str - """ - The MIME type of the audio. Different providers may support different - audio types. - """ - annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ToolUseContent(BaseModel): - """ - Content representing an assistant's request to invoke a tool. - - This content type appears in assistant messages when the LLM wants to call a tool - during sampling. The server should execute the tool and return a ToolResultContent - in the next user message. - """ - - type: Literal["tool_use"] - """Discriminator for tool use content.""" - - name: str - """The name of the tool to invoke. Must match a tool name from the request's tools array.""" - - id: str - """Unique identifier for this tool call, used to correlate with ToolResultContent.""" - - input: dict[str, Any] - """Arguments to pass to the tool. Must conform to the tool's inputSchema.""" - - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ToolResultContent(BaseModel): - """ - Content representing the result of a tool execution. - - This content type appears in user messages as a response to a ToolUseContent - from the assistant. It contains the output of executing the requested tool. - """ - - type: Literal["tool_result"] - """Discriminator for tool result content.""" - - toolUseId: str - """The unique identifier that corresponds to the tool call's id field.""" - - content: list["ContentBlock"] = [] - """ - A list of content objects representing the tool result. - Defaults to empty list if not provided. - """ - - structuredContent: dict[str, Any] | None = None - """ - Optional structured tool output that matches the tool's outputSchema (if defined). - """ - - isError: bool | None = None - """Whether the tool execution resulted in an error.""" - - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -SamplingMessageContentBlock: TypeAlias = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent -"""Content block types allowed in sampling messages.""" - -SamplingContent: TypeAlias = TextContent | ImageContent | AudioContent -"""Basic content types for sampling responses (without tool use). -Used for backwards-compatible CreateMessageResult when tools are not used.""" - - -class SamplingMessage(BaseModel): - """Describes a message issued to or received from an LLM API.""" - - role: Role - content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] - """ - Message content. Can be a single content block or an array of content blocks - for multi-modal messages and tool interactions. - """ - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - @property - def content_as_list(self) -> list[SamplingMessageContentBlock]: - """Returns the content as a list of content blocks, regardless of whether - it was originally a single block or a list.""" - return self.content if isinstance(self.content, list) else [self.content] - - -class EmbeddedResource(BaseModel): - """ - The contents of a resource, embedded into a prompt or tool call result. - - It is up to the client how best to render embedded resources for the benefit - of the LLM and/or the user. - """ - - type: Literal["resource"] - resource: TextResourceContents | BlobResourceContents - annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ResourceLink(Resource): - """ - A resource that the server is capable of reading, included in a prompt or tool call result. - - Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. - """ - - type: Literal["resource_link"] - - -ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource -"""A content block that can be used in prompts and tool results.""" - -Content: TypeAlias = ContentBlock -# """DEPRECATED: Content is deprecated, you should use ContentBlock directly.""" - - -class PromptMessage(BaseModel): - """Describes a message returned as part of a prompt.""" - - role: Role - content: ContentBlock - model_config = ConfigDict(extra="allow") - - -class GetPromptResult(Result): - """The server's response to a prompts/get request from the client.""" - - description: str | None = None - """An optional description for the prompt.""" - messages: list[PromptMessage] - - -class PromptListChangedNotification( - Notification[NotificationParams | None, Literal["notifications/prompts/list_changed"]] -): - """ - An optional notification from the server to the client, informing it that the list - of prompts it offers has changed. - """ - - method: Literal["notifications/prompts/list_changed"] = "notifications/prompts/list_changed" - params: NotificationParams | None = None - - -class ListToolsRequest(PaginatedRequest[Literal["tools/list"]]): - """Sent from the client to request a list of tools the server has.""" - - method: Literal["tools/list"] = "tools/list" - - -class ToolAnnotations(BaseModel): - """ - Additional properties describing a Tool to clients. - - NOTE: all properties in ToolAnnotations are **hints**. - They are not guaranteed to provide a faithful description of - tool behavior (including descriptive properties like `title`). - - Clients should never make tool use decisions based on ToolAnnotations - received from untrusted servers. - """ - - title: str | None = None - """A human-readable title for the tool.""" - - readOnlyHint: bool | None = None - """ - If true, the tool does not modify its environment. - Default: false - """ - - destructiveHint: bool | None = None - """ - If true, the tool may perform destructive updates to its environment. - If false, the tool performs only additive updates. - (This property is meaningful only when `readOnlyHint == false`) - Default: true - """ - - idempotentHint: bool | None = None - """ - If true, calling the tool repeatedly with the same arguments - will have no additional effect on the its environment. - (This property is meaningful only when `readOnlyHint == false`) - Default: false - """ - - openWorldHint: bool | None = None - """ - If true, this tool may interact with an "open world" of external - entities. If false, the tool's domain of interaction is closed. - For example, the world of a web search tool is open, whereas that - of a memory tool is not. - Default: true - """ - - model_config = ConfigDict(extra="allow") - - -class ToolExecution(BaseModel): - """Execution-related properties for a tool.""" - - model_config = ConfigDict(extra="allow") - - taskSupport: TaskExecutionMode | None = None - """ - Indicates whether this tool supports task-augmented execution. - This allows clients to handle long-running operations through polling - the task system. - - - "forbidden": Tool does not support task-augmented execution (default when absent) - - "optional": Tool may support task-augmented execution - - "required": Tool requires task-augmented execution - - Default: "forbidden" - """ - - -class Tool(BaseMetadata): - """Definition for a tool the client can call.""" - - description: str | None = None - """A human-readable description of the tool.""" - inputSchema: dict[str, Any] - """A JSON Schema object defining the expected parameters for the tool.""" - outputSchema: dict[str, Any] | None = None - """ - An optional JSON Schema object defining the structure of the tool's output - returned in the structuredContent field of a CallToolResult. - """ - icons: list[Icon] | None = None - """An optional list of icons for this tool.""" - annotations: ToolAnnotations | None = None - """Optional additional tool information.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - - execution: ToolExecution | None = None - - model_config = ConfigDict(extra="allow") - - -class ListToolsResult(PaginatedResult): - """The server's response to a tools/list request from the client.""" - - tools: list[Tool] - - -class CallToolRequestParams(RequestParams): - """Parameters for calling a tool.""" - - name: str - arguments: dict[str, Any] | None = None - model_config = ConfigDict(extra="allow") - - -class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): - """Used by the client to invoke a tool provided by the server.""" - - method: Literal["tools/call"] = "tools/call" - params: CallToolRequestParams - - -class CallToolResult(Result): - """The server's response to a tool call.""" - - content: list[ContentBlock] - structuredContent: dict[str, Any] | None = None - """An optional JSON object that represents the structured result of the tool call.""" - isError: bool = False - - -class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]): - """ - An optional notification from the server to the client, informing it that the list - of tools it offers has changed. - """ - - method: Literal["notifications/tools/list_changed"] = "notifications/tools/list_changed" - params: NotificationParams | None = None - - -LoggingLevel = Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"] - - -class SetLevelRequestParams(RequestParams): - """Parameters for setting the logging level.""" - - level: LoggingLevel - """The level of logging that the client wants to receive from the server.""" - model_config = ConfigDict(extra="allow") - - -class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]]): - """A request from the client to the server, to enable or adjust logging.""" - - method: Literal["logging/setLevel"] = "logging/setLevel" - params: SetLevelRequestParams - - -class LoggingMessageNotificationParams(NotificationParams): - """Parameters for logging message notifications.""" - - level: LoggingLevel - """The severity of this log message.""" - logger: str | None = None - """An optional name of the logger issuing this message.""" - data: Any - """ - The data to be logged, such as a string message or an object. Any JSON serializable - type is allowed here. - """ - model_config = ConfigDict(extra="allow") - - -class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, Literal["notifications/message"]]): - """Notification of a log message passed from server to client.""" - - method: Literal["notifications/message"] = "notifications/message" - params: LoggingMessageNotificationParams - - -IncludeContext = Literal["none", "thisServer", "allServers"] - - -class ModelHint(BaseModel): - """Hints to use for model selection.""" - - name: str | None = None - """A hint for a model name.""" - - model_config = ConfigDict(extra="allow") - - -class ModelPreferences(BaseModel): - """ - The server's preferences for model selection, requested by the client during - sampling. - - Because LLMs can vary along multiple dimensions, choosing the "best" model is - rarely straightforward. Different models excel in different areas—some are - faster but less capable, others are more capable but more expensive, and so - on. This interface allows servers to express their priorities across multiple - dimensions to help clients make an appropriate selection for their use case. - - These preferences are always advisory. The client MAY ignore them. It is also - up to the client to decide how to interpret these preferences and how to - balance them against other considerations. - """ - - hints: list[ModelHint] | None = None - """ - Optional hints to use for model selection. - - If multiple hints are specified, the client MUST evaluate them in order - (such that the first match is taken). - - The client SHOULD prioritize these hints over the numeric priorities, but - MAY still use the priorities to select from ambiguous matches. - """ - - costPriority: float | None = None - """ - How much to prioritize cost when selecting a model. A value of 0 means cost - is not important, while a value of 1 means cost is the most important - factor. - """ - - speedPriority: float | None = None - """ - How much to prioritize sampling speed (latency) when selecting a model. A - value of 0 means speed is not important, while a value of 1 means speed is - the most important factor. - """ - - intelligencePriority: float | None = None - """ - How much to prioritize intelligence and capabilities when selecting a - model. A value of 0 means intelligence is not important, while a value of 1 - means intelligence is the most important factor. - """ - - model_config = ConfigDict(extra="allow") - - -class ToolChoice(BaseModel): - """ - Controls tool usage behavior during sampling. - - Allows the server to specify whether and how the LLM should use tools - in its response. - """ - - mode: Literal["auto", "required", "none"] | None = None - """ - Controls when tools are used: - - "auto": Model decides whether to use tools (default) - - "required": Model MUST use at least one tool before completing - - "none": Model should not use tools - """ - - model_config = ConfigDict(extra="allow") - - -class CreateMessageRequestParams(RequestParams): - """Parameters for creating a message.""" - - messages: list[SamplingMessage] - modelPreferences: ModelPreferences | None = None - """ - The server's preferences for which model to select. The client MAY ignore - these preferences. - """ - systemPrompt: str | None = None - """An optional system prompt the server wants to use for sampling.""" - includeContext: IncludeContext | None = None - """ - A request to include context from one or more MCP servers (including the caller), to - be attached to the prompt. - """ - temperature: float | None = None - maxTokens: int - """The maximum number of tokens to sample, as requested by the server.""" - stopSequences: list[str] | None = None - metadata: dict[str, Any] | None = None - """Optional metadata to pass through to the LLM provider.""" - tools: list["Tool"] | None = None - """ - Tool definitions for the LLM to use during sampling. - Requires clientCapabilities.sampling.tools to be present. - """ - toolChoice: ToolChoice | None = None - """ - Controls tool usage behavior. - Requires clientCapabilities.sampling.tools and the tools parameter to be present. - """ - model_config = ConfigDict(extra="allow") - - -class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling/createMessage"]]): - """A request from the server to sample an LLM via the client.""" - - method: Literal["sampling/createMessage"] = "sampling/createMessage" - params: CreateMessageRequestParams - - -StopReason = Literal["endTurn", "stopSequence", "maxTokens", "toolUse"] | str - - -class CreateMessageResult(Result): - """The client's response to a sampling/create_message request from the server. - - This is the backwards-compatible version that returns single content (no arrays). - Used when the request does not include tools. - """ - - role: Role - """The role of the message sender (typically 'assistant' for LLM responses).""" - content: SamplingContent - """Response content. Single content block (text, image, or audio).""" - model: str - """The name of the model that generated the message.""" - stopReason: StopReason | None = None - """The reason why sampling stopped, if known.""" - - -class CreateMessageResultWithTools(Result): - """The client's response to a sampling/create_message request when tools were provided. - - This version supports array content for tool use flows. - """ - - role: Role - """The role of the message sender (typically 'assistant' for LLM responses).""" - content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] - """ - Response content. May be a single content block or an array. - May include ToolUseContent if stopReason is 'toolUse'. - """ - model: str - """The name of the model that generated the message.""" - stopReason: StopReason | None = None - """ - The reason why sampling stopped, if known. - 'toolUse' indicates the model wants to use a tool. - """ - - @property - def content_as_list(self) -> list[SamplingMessageContentBlock]: - """Returns the content as a list of content blocks, regardless of whether - it was originally a single block or a list.""" - return self.content if isinstance(self.content, list) else [self.content] - - -class ResourceTemplateReference(BaseModel): - """A reference to a resource or resource template definition.""" - - type: Literal["ref/resource"] - uri: str - """The URI or URI template of the resource.""" - model_config = ConfigDict(extra="allow") - - -@deprecated("`ResourceReference` is deprecated, you should use `ResourceTemplateReference`.") -class ResourceReference(ResourceTemplateReference): - pass - - -class PromptReference(BaseModel): - """Identifies a prompt.""" - - type: Literal["ref/prompt"] - name: str - """The name of the prompt or prompt template""" - model_config = ConfigDict(extra="allow") - - -class CompletionArgument(BaseModel): - """The argument's information for completion requests.""" - - name: str - """The name of the argument""" - value: str - """The value of the argument to use for completion matching.""" - model_config = ConfigDict(extra="allow") - - -class CompletionContext(BaseModel): - """Additional, optional context for completions.""" - - arguments: dict[str, str] | None = None - """Previously-resolved variables in a URI template or prompt.""" - model_config = ConfigDict(extra="allow") - - -class CompleteRequestParams(RequestParams): - """Parameters for completion requests.""" - - ref: ResourceTemplateReference | PromptReference - argument: CompletionArgument - context: CompletionContext | None = None - """Additional, optional context for completions""" - model_config = ConfigDict(extra="allow") - - -class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complete"]]): - """A request from the client to the server, to ask for completion options.""" - - method: Literal["completion/complete"] = "completion/complete" - params: CompleteRequestParams - - -class Completion(BaseModel): - """Completion information.""" - - values: list[str] - """An array of completion values. Must not exceed 100 items.""" - total: int | None = None - """ - The total number of completion options available. This can exceed the number of - values actually sent in the response. - """ - hasMore: bool | None = None - """ - Indicates whether there are additional completion options beyond those provided in - the current response, even if the exact total is unknown. - """ - model_config = ConfigDict(extra="allow") - - -class CompleteResult(Result): - """The server's response to a completion/complete request""" - - completion: Completion - - -class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]): - """ - Sent from the server to request a list of root URIs from the client. Roots allow - servers to ask for specific directories or files to operate on. A common example - for roots is providing a set of repositories or directories a server should operate - on. - - This request is typically used when the server needs to understand the file system - structure or access specific locations that the client has permission to read from. - """ - - method: Literal["roots/list"] = "roots/list" - params: RequestParams | None = None - - -class Root(BaseModel): - """Represents a root directory or file that the server can operate on.""" - - uri: FileUrl - """ - The URI identifying the root. This *must* start with file:// for now. - This restriction may be relaxed in future versions of the protocol to allow - other URI schemes. - """ - name: str | None = None - """ - An optional name for the root. This can be used to provide a human-readable - identifier for the root, which may be useful for display purposes or for - referencing the root in other parts of the application. - """ - meta: dict[str, Any] | None = Field(alias="_meta", default=None) - """ - See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) - for notes on _meta usage. - """ - model_config = ConfigDict(extra="allow") - - -class ListRootsResult(Result): - """ - The client's response to a roots/list request from the server. - This result contains an array of Root objects, each representing a root directory - or file that the server can operate on. - """ - - roots: list[Root] - - -class RootsListChangedNotification( - Notification[NotificationParams | None, Literal["notifications/roots/list_changed"]] -): - """ - A notification from the client to the server, informing it that the list of - roots has changed. - - This notification should be sent whenever the client adds, removes, or - modifies any root. The server should then request an updated list of roots - using the ListRootsRequest. - """ - - method: Literal["notifications/roots/list_changed"] = "notifications/roots/list_changed" - params: NotificationParams | None = None - - -class CancelledNotificationParams(NotificationParams): - """Parameters for cancellation notifications.""" - - requestId: RequestId | None = None - """ - The ID of the request to cancel. - - This MUST correspond to the ID of a request previously issued in the same direction. - This MUST be provided for cancelling non-task requests. - This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). - """ - reason: str | None = None - """An optional string describing the reason for the cancellation.""" - - model_config = ConfigDict(extra="allow") - - -class CancelledNotification(Notification[CancelledNotificationParams, Literal["notifications/cancelled"]]): - """ - This notification can be sent by either side to indicate that it is canceling a - previously-issued request. - """ - - method: Literal["notifications/cancelled"] = "notifications/cancelled" - params: CancelledNotificationParams - - -class ElicitCompleteNotificationParams(NotificationParams): - """Parameters for elicitation completion notifications.""" - - elicitationId: str - """The unique identifier of the elicitation that was completed.""" - - model_config = ConfigDict(extra="allow") - - -class ElicitCompleteNotification( - Notification[ElicitCompleteNotificationParams, Literal["notifications/elicitation/complete"]] -): - """ - A notification from the server to the client, informing it that a URL mode - elicitation has been completed. - - Clients MAY use the notification to automatically retry requests that received a - URLElicitationRequiredError, update the user interface, or otherwise continue - an interaction. However, because delivery of the notification is not guaranteed, - clients must not wait indefinitely for a notification from the server. - """ - - method: Literal["notifications/elicitation/complete"] = "notifications/elicitation/complete" - params: ElicitCompleteNotificationParams - - -ClientRequestType: TypeAlias = ( - PingRequest - | InitializeRequest - | CompleteRequest - | SetLevelRequest - | GetPromptRequest - | ListPromptsRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscribeRequest - | UnsubscribeRequest - | CallToolRequest - | ListToolsRequest - | GetTaskRequest - | GetTaskPayloadRequest - | ListTasksRequest - | CancelTaskRequest -) - - -class ClientRequest(RootModel[ClientRequestType]): - pass - - -ClientNotificationType: TypeAlias = ( - CancelledNotification - | ProgressNotification - | InitializedNotification - | RootsListChangedNotification - | TaskStatusNotification -) - - -class ClientNotification(RootModel[ClientNotificationType]): - pass - - -# Type for elicitation schema - a JSON Schema dict -ElicitRequestedSchema: TypeAlias = dict[str, Any] -"""Schema for elicitation requests.""" - - -class ElicitRequestFormParams(RequestParams): - """Parameters for form mode elicitation requests. - - Form mode collects non-sensitive information from the user via an in-band form - rendered by the client. - """ - - mode: Literal["form"] = "form" - """The elicitation mode (always "form" for this type).""" - - message: str - """The message to present to the user describing what information is being requested.""" - - requestedSchema: ElicitRequestedSchema - """ - A restricted subset of JSON Schema defining the structure of expected response. - Only top-level properties are allowed, without nesting. - """ - - model_config = ConfigDict(extra="allow") - - -class ElicitRequestURLParams(RequestParams): - """Parameters for URL mode elicitation requests. - - URL mode directs users to external URLs for sensitive out-of-band interactions - like OAuth flows, credential collection, or payment processing. - """ - - mode: Literal["url"] = "url" - """The elicitation mode (always "url" for this type).""" - - message: str - """The message to present to the user explaining why the interaction is needed.""" - - url: str - """The URL that the user should navigate to.""" - - elicitationId: str - """ - The ID of the elicitation, which must be unique within the context of the server. - The client MUST treat this ID as an opaque value. - """ - - model_config = ConfigDict(extra="allow") - - -# Union type for elicitation request parameters -ElicitRequestParams: TypeAlias = ElicitRequestURLParams | ElicitRequestFormParams -"""Parameters for elicitation requests - either form or URL mode.""" - - -class ElicitRequest(Request[ElicitRequestParams, Literal["elicitation/create"]]): - """A request from the server to elicit information from the client.""" - - method: Literal["elicitation/create"] = "elicitation/create" - params: ElicitRequestParams - - -class ElicitResult(Result): - """The client's response to an elicitation request.""" - - action: Literal["accept", "decline", "cancel"] - """ - The user action in response to the elicitation. - - "accept": User submitted the form/confirmed the action (or consented to URL navigation) - - "decline": User explicitly declined the action - - "cancel": User dismissed without making an explicit choice - """ - - content: dict[str, str | int | float | bool | list[str] | None] | None = None - """ - The submitted form data, only present when action is "accept" in form mode. - Contains values matching the requested schema. Values can be strings, integers, - booleans, or arrays of strings. - For URL mode, this field is omitted. - """ - - -class ElicitationRequiredErrorData(BaseModel): - """Error data for URLElicitationRequiredError. - - Servers return this when a request cannot be processed until one or more - URL mode elicitations are completed. - """ - - elicitations: list[ElicitRequestURLParams] - """List of URL mode elicitations that must be completed.""" - - model_config = ConfigDict(extra="allow") - - -ClientResultType: TypeAlias = ( - EmptyResult - | CreateMessageResult - | CreateMessageResultWithTools - | ListRootsResult - | ElicitResult - | GetTaskResult - | GetTaskPayloadResult - | ListTasksResult - | CancelTaskResult - | CreateTaskResult -) - - -class ClientResult(RootModel[ClientResultType]): - pass - - -ServerRequestType: TypeAlias = ( - PingRequest - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest - | GetTaskRequest - | GetTaskPayloadRequest - | ListTasksRequest - | CancelTaskRequest -) - - -class ServerRequest(RootModel[ServerRequestType]): - pass - - -ServerNotificationType: TypeAlias = ( - CancelledNotification - | ProgressNotification - | LoggingMessageNotification - | ResourceUpdatedNotification - | ResourceListChangedNotification - | ToolListChangedNotification - | PromptListChangedNotification - | ElicitCompleteNotification - | TaskStatusNotification -) - - -class ServerNotification(RootModel[ServerNotificationType]): - pass - - -ServerResultType: TypeAlias = ( - EmptyResult - | InitializeResult - | CompleteResult - | GetPromptResult - | ListPromptsResult - | ListResourcesResult - | ListResourceTemplatesResult - | ReadResourceResult - | CallToolResult - | ListToolsResult - | GetTaskResult - | GetTaskPayloadResult - | ListTasksResult - | CancelTaskResult - | CreateTaskResult -) - - -class ServerResult(RootModel[ServerResultType]): - pass diff --git a/src/agent_workflow/mcp/sdk/uv.lock b/src/agent_workflow/mcp/sdk/uv.lock deleted file mode 100644 index 43e6218..0000000 --- a/src/agent_workflow/mcp/sdk/uv.lock +++ /dev/null @@ -1,2844 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", -] - -[manifest] -members = [ - "mcp", - "mcp-conformance-auth-client", - "mcp-everything-server", - "mcp-simple-auth", - "mcp-simple-auth-client", - "mcp-simple-chatbot", - "mcp-simple-pagination", - "mcp-simple-prompt", - "mcp-simple-resource", - "mcp-simple-streamablehttp", - "mcp-simple-streamablehttp-stateless", - "mcp-simple-task", - "mcp-simple-task-client", - "mcp-simple-task-interactive", - "mcp-simple-task-interactive-client", - "mcp-simple-tool", - "mcp-snippets", - "mcp-sse-polling-client", - "mcp-sse-polling-demo", - "mcp-structured-output-lowlevel", -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, -] - -[[package]] -name = "asttokens" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, -] - -[[package]] -name = "attrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, -] - -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - -[[package]] -name = "backrefs" -version = "5.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, - { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, - { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, -] - -[[package]] -name = "black" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, - { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, - { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, - { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, - { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, - { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, - { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, - { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, -] - -[[package]] -name = "cairocffi" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, -] - -[[package]] -name = "cairosvg" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cairocffi" }, - { name = "cssselect2" }, - { name = "defusedxml" }, - { name = "pillow" }, - { name = "tinycss2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" }, -] - -[[package]] -name = "certifi" -version = "2025.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, -] - -[[package]] -name = "click" -version = "8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "coverage" -version = "7.10.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - -[[package]] -name = "cryptography" -version = "45.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, - { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, - { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, - { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, - { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, - { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, - { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, - { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, - { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, - { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, - { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, - { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, - { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, - { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, -] - -[[package]] -name = "cssselect2" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tinycss2" }, - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - -[[package]] -name = "dirty-equals" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/99/133892f401ced5a27e641a473c547d5fbdb39af8f85dac8a9d633ea3e7a7/dirty_equals-0.9.0.tar.gz", hash = "sha256:17f515970b04ed7900b733c95fd8091f4f85e52f1fb5f268757f25c858eb1f7b", size = 50412, upload-time = "2025-01-11T23:23:40.491Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226, upload-time = "2025-01-11T23:23:37.489Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "execnet" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, -] - -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - -[[package]] -name = "ghp-import" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, -] - -[[package]] -name = "griffe" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "inline-snapshot" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pytest" }, - { name = "rich" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/9e/83aaa750e9c8115d34b2d80646c1988941f2252c5548caf35aad5e529bad/inline_snapshot-0.28.0.tar.gz", hash = "sha256:6904bfc383240b6bea64de2f5d2992f04109b13def19395bdd13fb0ebcf5cf20", size = 348554, upload-time = "2025-08-24T21:48:04.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/04/190b336a006d4e1275c2dde1bf953336e818d18b779f24947579bb4ba48d/inline_snapshot-0.28.0-py3-none-any.whl", hash = "sha256:9988f82ee5e719445bbc437d0dc01e0a3c4c94f0ba910f8ad8b573cf15aa8348", size = 69026, upload-time = "2025-08-24T21:48:02.342Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "markdown" -version = "3.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "mcp" -source = { editable = "." } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] - -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] -rich = [ - { name = "rich" }, -] -ws = [ - { name = "websockets" }, -] - -[package.dev-dependencies] -dev = [ - { name = "coverage", extra = ["toml"] }, - { name = "dirty-equals" }, - { name = "inline-snapshot" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-examples" }, - { name = "pytest-flakefinder" }, - { name = "pytest-pretty" }, - { name = "pytest-xdist" }, - { name = "ruff" }, - { name = "trio" }, -] -docs = [ - { name = "mkdocs" }, - { name = "mkdocs-glightbox" }, - { name = "mkdocs-material", extra = ["imaging"] }, - { name = "mkdocstrings-python" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, - { name = "httpx-sse", specifier = ">=0.4" }, - { name = "jsonschema", specifier = ">=4.20.0" }, - { name = "pydantic", marker = "python_full_version < '3.14'", specifier = ">=2.11.0,<3.0.0" }, - { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0,<3.0.0" }, - { name = "pydantic-settings", specifier = ">=2.5.2" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, - { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, - { name = "python-multipart", specifier = ">=0.0.9" }, - { name = "pywin32", marker = "python_full_version < '3.14' and sys_platform == 'win32'", specifier = ">=310" }, - { name = "pywin32", marker = "python_full_version >= '3.14' and sys_platform == 'win32'", specifier = ">=311" }, - { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, - { name = "sse-starlette", specifier = ">=1.6.1" }, - { name = "starlette", marker = "python_full_version < '3.14'", specifier = ">=0.27" }, - { name = "starlette", marker = "python_full_version >= '3.14'", specifier = ">=0.48.0" }, - { name = "typer", marker = "extra == 'cli'", specifier = ">=0.16.0" }, - { name = "typing-extensions", specifier = ">=4.9.0" }, - { name = "typing-inspection", specifier = ">=0.4.1" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, - { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, -] -provides-extras = ["cli", "rich", "ws"] - -[package.metadata.requires-dev] -dev = [ - { name = "coverage", extras = ["toml"], specifier = "==7.10.7" }, - { name = "dirty-equals", specifier = ">=0.9.0" }, - { name = "inline-snapshot", specifier = ">=0.23.0" }, - { name = "pyright", specifier = ">=1.1.400" }, - { name = "pytest", specifier = ">=8.3.4" }, - { name = "pytest-examples", specifier = ">=0.0.14" }, - { name = "pytest-flakefinder", specifier = ">=1.1.0" }, - { name = "pytest-pretty", specifier = ">=1.2.0" }, - { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "ruff", specifier = ">=0.8.5" }, - { name = "trio", specifier = ">=0.26.2" }, -] -docs = [ - { name = "mkdocs", specifier = ">=1.6.1" }, - { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, - { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.6.19" }, - { name = "mkdocstrings-python", specifier = ">=1.12.2" }, -] - -[[package]] -name = "mcp-conformance-auth-client" -version = "0.1.0" -source = { editable = "examples/clients/conformance-auth-client" } -dependencies = [ - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-everything-server" -version = "0.1.0" -source = { editable = "examples/servers/everything-server" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-auth" -version = "0.1.0" -source = { editable = "examples/servers/simple-auth" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pydantic-settings", specifier = ">=2.5.2" }, - { name = "sse-starlette", specifier = ">=1.6.1" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.391" }, - { name = "pytest", specifier = ">=8.3.4" }, - { name = "ruff", specifier = ">=0.8.5" }, -] - -[[package]] -name = "mcp-simple-auth-client" -version = "0.1.0" -source = { editable = "examples/clients/simple-auth-client" } -dependencies = [ - { name = "click" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-chatbot" -version = "0.1.0" -source = { editable = "examples/clients/simple-chatbot" } -dependencies = [ - { name = "mcp" }, - { name = "python-dotenv" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "mcp", editable = "." }, - { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "uvicorn", specifier = ">=0.32.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-pagination" -version = "0.1.0" -source = { editable = "examples/servers/simple-pagination" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-prompt" -version = "0.1.0" -source = { editable = "examples/servers/simple-prompt" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-resource" -version = "0.1.0" -source = { editable = "examples/servers/simple-resource" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-streamablehttp" -version = "0.1.0" -source = { editable = "examples/servers/simple-streamablehttp" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-streamablehttp-stateless" -version = "0.1.0" -source = { editable = "examples/servers/simple-streamablehttp-stateless" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-task" -version = "0.1.0" -source = { editable = "examples/servers/simple-task" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.0" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-task-client" -version = "0.1.0" -source = { editable = "examples/clients/simple-task-client" } -dependencies = [ - { name = "click" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.0" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-task-interactive" -version = "0.1.0" -source = { editable = "examples/servers/simple-task-interactive" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.0" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-task-interactive-client" -version = "0.1.0" -source = { editable = "examples/clients/simple-task-interactive-client" } -dependencies = [ - { name = "click" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.0" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-tool" -version = "0.1.0" -source = { editable = "examples/servers/simple-tool" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-snippets" -version = "0.1.0" -source = { editable = "examples/snippets" } -dependencies = [ - { name = "mcp" }, -] - -[package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] - -[[package]] -name = "mcp-sse-polling-client" -version = "0.1.0" -source = { editable = "examples/clients/sse-polling-client" } -dependencies = [ - { name = "click" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-sse-polling-demo" -version = "0.1.0" -source = { editable = "examples/servers/sse-polling-demo" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-structured-output-lowlevel" -version = "0.1.0" -source = { virtual = "examples/servers/structured-output-lowlevel" } -dependencies = [ - { name = "mcp" }, -] - -[package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, -] - -[[package]] -name = "mkdocs-autorefs" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, -] - -[[package]] -name = "mkdocs-glightbox" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "selectolax" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/72/c03e9d8d2dbe098d7ce5d51309933a1d3aea268965ed097ab16f4b54de15/mkdocs_glightbox-0.5.1.tar.gz", hash = "sha256:7d78a5b045f2479f61b0bbb17742ba701755c56b013e70ac189c9d87a91e80bf", size = 480028, upload-time = "2025-09-04T13:10:29.679Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/cf/e9a0ce9da269746906fdc595c030f6df66793dad1487abd1699af2ba44f1/mkdocs_glightbox-0.5.1-py3-none-any.whl", hash = "sha256:f47af0daff164edf8d36e553338425be3aab6e34b987d9cbbc2ae7819a98cb01", size = 26431, upload-time = "2025-09-04T13:10:27.933Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.6.19" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "click" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/44/94/eb0fca39b19c2251b16bc759860a50f232655c4377116fa9c0e7db11b82c/mkdocs_material-9.6.19.tar.gz", hash = "sha256:80e7b3f9acabfee9b1f68bd12c26e59c865b3d5bbfb505fd1344e970db02c4aa", size = 4038202, upload-time = "2025-09-07T17:46:40.468Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/23/a2551d1038bedc2771366f65ff3680bb3a89674cd7ca6140850c859f1f71/mkdocs_material-9.6.19-py3-none-any.whl", hash = "sha256:7492d2ac81952a467ca8a10cac915d6ea5c22876932f44b5a0f4f8e7d68ac06f", size = 9240205, upload-time = "2025-09-07T17:46:36.484Z" }, -] - -[package.optional-dependencies] -imaging = [ - { name = "cairosvg" }, - { name = "pillow" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - -[[package]] -name = "mkdocstrings" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "pymdown-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/0a/7e4776217d4802009c8238c75c5345e23014a4706a8414a62c0498858183/mkdocstrings-0.30.0.tar.gz", hash = "sha256:5d8019b9c31ddacd780b6784ffcdd6f21c408f34c0bd1103b5351d609d5b4444", size = 106597, upload-time = "2025-07-22T23:48:45.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/b4/3c5eac68f31e124a55d255d318c7445840fa1be55e013f507556d6481913/mkdocstrings-0.30.0-py3-none-any.whl", hash = "sha256:ae9e4a0d8c1789697ac776f2e034e2ddd71054ae1cf2c2bb1433ccfd07c226f2", size = 36579, upload-time = "2025-07-22T23:48:44.152Z" }, -] - -[[package]] -name = "mkdocstrings-python" -version = "1.18.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffe" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocstrings" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972, upload-time = "2025-08-28T16:11:19.847Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - -[[package]] -name = "pillow" -version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, - { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, - { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, - { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, - { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, - { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, - { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - -[[package]] -name = "pydantic" -version = "2.11.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "annotated-types", marker = "python_full_version < '3.14'" }, - { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "typing-inspection", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.14'" }, - { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, - { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "python-dotenv" }, - { name = "typing-inspection", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pymdown-extensions" -version = "10.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.405" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest-examples" -version = "0.0.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "black" }, - { name = "pytest" }, - { name = "ruff" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/71/4ae972fd95f474454aa450108ee1037830e7ba11840363e981b8d48fd16a/pytest_examples-0.0.18.tar.gz", hash = "sha256:9a464f007f805b113677a15e2f8942ebb92d7d3eb5312e9a405d018478ec9801", size = 21237, upload-time = "2025-05-06T07:46:10.705Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" }, -] - -[[package]] -name = "pytest-flakefinder" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795, upload-time = "2022-10-26T18:27:54.243Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" }, -] - -[[package]] -name = "pytest-pretty" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/d7/c699e0be5401fe9ccad484562f0af9350b4e48c05acf39fb3dab1932128f/pytest_pretty-1.3.0.tar.gz", hash = "sha256:97e9921be40f003e40ae78db078d4a0c1ea42bf73418097b5077970c2cc43bf3", size = 219297, upload-time = "2025-06-04T12:54:37.322Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/85/2f97a1b65178b0f11c9c77c35417a4cc5b99a80db90dad4734a129844ea5/pytest_pretty-1.3.0-py3-none-any.whl", hash = "sha256:074b9d5783cef9571494543de07e768a4dda92a3e85118d6c7458c67297159b7", size = 5620, upload-time = "2025-06-04T12:54:36.229Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, -] - -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, -] - -[[package]] -name = "referencing" -version = "0.36.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "rich" -version = "14.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.27.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, - { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, - { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, - { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, - { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, - { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, - { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, - { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c1/7907329fbef97cbd49db6f7303893bd1dd5a4a3eae415839ffdfb0762cae/rpds_py-0.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:be898f271f851f68b318872ce6ebebbc62f303b654e43bf72683dbdc25b7c881", size = 371063, upload-time = "2025-08-27T12:12:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/2aab4bc86228bcf7c48760990273653a4900de89c7537ffe1b0d6097ed39/rpds_py-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62ac3d4e3e07b58ee0ddecd71d6ce3b1637de2d373501412df395a0ec5f9beb5", size = 353210, upload-time = "2025-08-27T12:12:49.187Z" }, - { url = "https://files.pythonhosted.org/packages/3a/57/f5eb3ecf434342f4f1a46009530e93fd201a0b5b83379034ebdb1d7c1a58/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4708c5c0ceb2d034f9991623631d3d23cb16e65c83736ea020cdbe28d57c0a0e", size = 381636, upload-time = "2025-08-27T12:12:50.492Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f4/ef95c5945e2ceb5119571b184dd5a1cc4b8541bbdf67461998cfeac9cb1e/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:abfa1171a9952d2e0002aba2ad3780820b00cc3d9c98c6630f2e93271501f66c", size = 394341, upload-time = "2025-08-27T12:12:52.024Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7e/4bd610754bf492d398b61725eb9598ddd5eb86b07d7d9483dbcd810e20bc/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b507d19f817ebaca79574b16eb2ae412e5c0835542c93fe9983f1e432aca195", size = 523428, upload-time = "2025-08-27T12:12:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e5/059b9f65a8c9149361a8b75094864ab83b94718344db511fd6117936ed2a/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168b025f8fd8d8d10957405f3fdcef3dc20f5982d398f90851f4abc58c566c52", size = 402923, upload-time = "2025-08-27T12:12:55.15Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/64cabb7daced2968dd08e8a1b7988bf358d7bd5bcd5dc89a652f4668543c/rpds_py-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb56c6210ef77caa58e16e8c17d35c63fe3f5b60fd9ba9d424470c3400bcf9ed", size = 384094, upload-time = "2025-08-27T12:12:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e1/dc9094d6ff566bff87add8a510c89b9e158ad2ecd97ee26e677da29a9e1b/rpds_py-0.27.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:d252f2d8ca0195faa707f8eb9368955760880b2b42a8ee16d382bf5dd807f89a", size = 401093, upload-time = "2025-08-27T12:12:58.985Z" }, - { url = "https://files.pythonhosted.org/packages/37/8e/ac8577e3ecdd5593e283d46907d7011618994e1d7ab992711ae0f78b9937/rpds_py-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6e5e54da1e74b91dbc7996b56640f79b195d5925c2b78efaa8c5d53e1d88edde", size = 417969, upload-time = "2025-08-27T12:13:00.367Z" }, - { url = "https://files.pythonhosted.org/packages/66/6d/87507430a8f74a93556fe55c6485ba9c259949a853ce407b1e23fea5ba31/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ffce0481cc6e95e5b3f0a47ee17ffbd234399e6d532f394c8dce320c3b089c21", size = 558302, upload-time = "2025-08-27T12:13:01.737Z" }, - { url = "https://files.pythonhosted.org/packages/3a/bb/1db4781ce1dda3eecc735e3152659a27b90a02ca62bfeea17aee45cc0fbc/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a205fdfe55c90c2cd8e540ca9ceba65cbe6629b443bc05db1f590a3db8189ff9", size = 589259, upload-time = "2025-08-27T12:13:03.127Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/ae1c8943d11a814d01b482e1f8da903f88047a962dff9bbdadf3bd6e6fd1/rpds_py-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:689fb5200a749db0415b092972e8eba85847c23885c8543a8b0f5c009b1a5948", size = 554983, upload-time = "2025-08-27T12:13:04.516Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/0b2a55415931db4f112bdab072443ff76131b5ac4f4dc98d10d2d357eb03/rpds_py-0.27.1-cp311-cp311-win32.whl", hash = "sha256:3182af66048c00a075010bc7f4860f33913528a4b6fc09094a6e7598e462fe39", size = 217154, upload-time = "2025-08-27T12:13:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/3b7ffe0d50dc86a6a964af0d1cc3a4a2cdf437cb7b099a4747bbb96d1819/rpds_py-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:b4938466c6b257b2f5c4ff98acd8128ec36b5059e5c8f8372d79316b1c36bb15", size = 228627, upload-time = "2025-08-27T12:13:07.625Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3f/4fd04c32abc02c710f09a72a30c9a55ea3cc154ef8099078fd50a0596f8e/rpds_py-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:2f57af9b4d0793e53266ee4325535a31ba48e2f875da81a9177c9926dfa60746", size = 220998, upload-time = "2025-08-27T12:13:08.972Z" }, - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, - { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, - { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, - { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, - { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, - { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ed/e1fba02de17f4f76318b834425257c8ea297e415e12c68b4361f63e8ae92/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdfe4bb2f9fe7458b7453ad3c33e726d6d1c7c0a72960bcc23800d77384e42df", size = 371402, upload-time = "2025-08-27T12:15:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/af/7c/e16b959b316048b55585a697e94add55a4ae0d984434d279ea83442e460d/rpds_py-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8fabb8fd848a5f75a2324e4a84501ee3a5e3c78d8603f83475441866e60b94a3", size = 354084, upload-time = "2025-08-27T12:15:53.219Z" }, - { url = "https://files.pythonhosted.org/packages/de/c1/ade645f55de76799fdd08682d51ae6724cb46f318573f18be49b1e040428/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda8719d598f2f7f3e0f885cba8646644b55a187762bec091fa14a2b819746a9", size = 383090, upload-time = "2025-08-27T12:15:55.158Z" }, - { url = "https://files.pythonhosted.org/packages/1f/27/89070ca9b856e52960da1472efcb6c20ba27cfe902f4f23ed095b9cfc61d/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c64d07e95606ec402a0a1c511fe003873fa6af630bda59bac77fac8b4318ebc", size = 394519, upload-time = "2025-08-27T12:15:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/b3/28/be120586874ef906aa5aeeae95ae8df4184bc757e5b6bd1c729ccff45ed5/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93a2ed40de81bcff59aabebb626562d48332f3d028ca2036f1d23cbb52750be4", size = 523817, upload-time = "2025-08-27T12:15:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/70cc197bc11cfcde02a86f36ac1eed15c56667c2ebddbdb76a47e90306da/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387ce8c44ae94e0ec50532d9cb0edce17311024c9794eb196b90e1058aadeb66", size = 403240, upload-time = "2025-08-27T12:16:00.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/46936cca449f7f518f2f4996e0e8344db4b57e2081e752441154089d2a5f/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf94f812c95b5e60ebaf8bfb1898a7d7cb9c1af5744d4a67fa47796e0465d4e", size = 385194, upload-time = "2025-08-27T12:16:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/e1/62/29c0d3e5125c3270b51415af7cbff1ec587379c84f55a5761cc9efa8cd06/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4848ca84d6ded9b58e474dfdbad4b8bfb450344c0551ddc8d958bf4b36aa837c", size = 402086, upload-time = "2025-08-27T12:16:04.806Z" }, - { url = "https://files.pythonhosted.org/packages/8f/66/03e1087679227785474466fdd04157fb793b3b76e3fcf01cbf4c693c1949/rpds_py-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bde09cbcf2248b73c7c323be49b280180ff39fadcfe04e7b6f54a678d02a7cf", size = 419272, upload-time = "2025-08-27T12:16:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/6a/24/e3e72d265121e00b063aef3e3501e5b2473cf1b23511d56e529531acf01e/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:94c44ee01fd21c9058f124d2d4f0c9dc7634bec93cd4b38eefc385dabe71acbf", size = 560003, upload-time = "2025-08-27T12:16:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/ca/f5a344c534214cc2d41118c0699fffbdc2c1bc7046f2a2b9609765ab9c92/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:df8b74962e35c9249425d90144e721eed198e6555a0e22a563d29fe4486b51f6", size = 590482, upload-time = "2025-08-27T12:16:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, -] - -[[package]] -name = "ruff" -version = "0.12.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, - { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, - { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, - { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, - { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, - { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, - { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, - { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, - { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, - { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, - { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, -] - -[[package]] -name = "selectolax" -version = "0.3.29" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/b9/b5a23e29d5e54c590eaad18bdbb1ced13b869b111e03d12ee0ae9eecf9b8/selectolax-0.3.29.tar.gz", hash = "sha256:28696fa4581765c705e15d05dfba464334f5f9bcb3eac9f25045f815aec6fbc1", size = 4691626, upload-time = "2025-04-30T15:17:37.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/8f/bf3d58ecc0e187806299324e2ad77646e837ff20400880f6fc0cbd14fb66/selectolax-0.3.29-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85aeae54f055cf5451828a21fbfecac99b8b5c27ec29fd10725b631593a7c9a3", size = 3643657, upload-time = "2025-04-30T15:15:40.734Z" }, - { url = "https://files.pythonhosted.org/packages/de/b0/6d90a4d0eacb8253d88a9fcbcb8758b667900f45dcdb4a11c5fbd0d31599/selectolax-0.3.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ff48efe4364c8148a553a4105773a0accee9cc25e0f2a40ddac44d18a5a3000", size = 2089380, upload-time = "2025-04-30T15:15:42.928Z" }, - { url = "https://files.pythonhosted.org/packages/f4/21/394b51998ef99f13f98da063fc71b8edf7191bb30aca06bcbc8a55d5a9ad/selectolax-0.3.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25cfccfefc41361ab8a07f15a224524a4a8b77dfa7d253b34bbd397e45856734", size = 5505065, upload-time = "2025-04-30T15:15:44.986Z" }, - { url = "https://files.pythonhosted.org/packages/dd/57/e38775b672f910e80742cbf7c3def5c670c1b6f9b05e8587b2fa8dc044c3/selectolax-0.3.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f5c3523ad5199a4fb9b95b6e24ff9222d3605023ca394b23f7dd910e7536daf", size = 5529205, upload-time = "2025-04-30T15:15:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0f/f6e3030107b486b6a4870f8471a675d435c4c34b8f9de3374652ed53004b/selectolax-0.3.29-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfb803d6bbe0ef3c8847cf5a01167cc428c0d9179946e1c994cc6178b5332d1a", size = 5146713, upload-time = "2025-04-30T15:15:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8d/b4fd119c216e8615ca6747f8f336643572178241921f33f5ffa4b074dc44/selectolax-0.3.29-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:db734ba4ef44fa3b57ad9374fd7ccfc7815c0ae5cfcbd5ee25fe8587092618d1", size = 5416352, upload-time = "2025-04-30T15:15:50.909Z" }, - { url = "https://files.pythonhosted.org/packages/d7/e7/94e694d14ae44bddc0d9b144647d5adbec0210d8e2c57d72ad9a133d9469/selectolax-0.3.29-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2bfe4327215a20af4197c5b7e3729a9552fb324bb57250dc7e7abfa0f848a463", size = 5140689, upload-time = "2025-04-30T15:15:52.477Z" }, - { url = "https://files.pythonhosted.org/packages/90/62/79ba965daa1f12e5477b2ec08b289f8289dfc705928b08923d9c4b60c867/selectolax-0.3.29-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a98c3f3d8fffb175456cb06096bc78103ddf6a209bea6392e0e4ea4e25aca71", size = 5481428, upload-time = "2025-04-30T15:15:54.371Z" }, - { url = "https://files.pythonhosted.org/packages/82/3c/46c1f0b739add89d0ef720ad521afaaf31b07a39f781ef9e59c7b5ecef44/selectolax-0.3.29-cp310-cp310-win32.whl", hash = "sha256:394d356ea611a7853c13c910a57c1a80a8356f9c920aa8168b3f8aaa62e433d8", size = 1702100, upload-time = "2025-04-30T15:15:55.833Z" }, - { url = "https://files.pythonhosted.org/packages/75/62/03350ed454fe26aef5580df498d45ace9f26ca6af1640ae681a6af1f5cdf/selectolax-0.3.29-cp310-cp310-win_amd64.whl", hash = "sha256:edd2760699c60dde7d847aebd81f02035f7bddcd0ad3db8e73326dfc84a2dc8f", size = 1807811, upload-time = "2025-04-30T15:15:57.243Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5d/ca72f7adddae4b2b128394a7559739a6a12c156d29b55968cfcfe07fac4d/selectolax-0.3.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6a1cd0518fa7656ea1683c4b2d3b5a98306753f364da9f673517847e1680a3e", size = 3649215, upload-time = "2025-04-30T15:15:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/08/c6/ca984f90b12fb10790cc56c2670f1b5f09884ed2f2012a219094b38cbcb4/selectolax-0.3.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e5354d805dd76b4b38002f58e6ae2e7b429ac311bf3601992a6662d2bc86911", size = 2091848, upload-time = "2025-04-30T15:16:01.73Z" }, - { url = "https://files.pythonhosted.org/packages/98/7f/c999ae6d9bfbaac3e8dea3dbb5ca6bdf61c220828e80a6c339e89f9db777/selectolax-0.3.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7073e3bcdc60ebdb5f8777c79b465471ec000ab556134da4e00f037d3321a2ec", size = 5638593, upload-time = "2025-04-30T15:16:03.594Z" }, - { url = "https://files.pythonhosted.org/packages/d6/32/ffd89376a888c24ecaf01fcffc5fe97b82ae03ab163158f51a559f1ebad5/selectolax-0.3.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47587db7cef411d22f8224cf2926aacdb326c4c838d386035229f16ccc2d8d26", size = 5668207, upload-time = "2025-04-30T15:16:05.564Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5c/2de0c7b8be75ad52d44706c67946181b972f27641ab4f6a1f27f46d2a603/selectolax-0.3.29-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:21de62b5093b1cb6c5d4cab0bef5f708b9ee1483b640d42be9d955becfcd287a", size = 5276654, upload-time = "2025-04-30T15:16:07.143Z" }, - { url = "https://files.pythonhosted.org/packages/29/29/152bb745b24072d3eecd3b395c756e74763111b9bbd265604f5b96b9a1aa/selectolax-0.3.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:af5cd03298cd75cb0fbf712d6ae4f8aca9c13a226d2821ca82f51cc9b33b032f", size = 5543731, upload-time = "2025-04-30T15:16:09.733Z" }, - { url = "https://files.pythonhosted.org/packages/04/1d/df65baaf16ece393f9f1a7c55f015510634adbb163ce72adcafaddf5cf9c/selectolax-0.3.29-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3f58dca53d2d3dc18dfd2cb9210a5625f32598db24e3f857f5be58f21a8f3b88", size = 5275005, upload-time = "2025-04-30T15:16:11.958Z" }, - { url = "https://files.pythonhosted.org/packages/5d/74/e56fd6f9b3087947b812f3862df3265bf5e21396d9673d076e999b1086cf/selectolax-0.3.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a6d8e02c6b9ba951d7b5a5dd2788a1d4bbdedc89782a4de165f1a87c4168ac", size = 5617441, upload-time = "2025-04-30T15:16:14.15Z" }, - { url = "https://files.pythonhosted.org/packages/63/d6/243049029bfc937b9f02faf4a4494e693575046414a475bf28ed9632b768/selectolax-0.3.29-cp311-cp311-win32.whl", hash = "sha256:912a1fc03157ebd066d8f59ae9ca2412ef95c7101a51590327c23071b02c97c7", size = 1701370, upload-time = "2025-04-30T15:16:16.339Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7f/baba8c5ce941c8cbd2dfb0c9f2253ba2d8c2d5d0fddda4f5a87eceb2484f/selectolax-0.3.29-cp311-cp311-win_amd64.whl", hash = "sha256:a3d44a295416b79815d2858ed4ccb71bf3b63087483a5d3705daa837c9dcf44d", size = 1808251, upload-time = "2025-04-30T15:16:18.289Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/ca4332eecc19124782f6f0d7cb28c331da2e9d9cf25287ba2b3b6a00cea1/selectolax-0.3.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6d3f373efd1db18ac9b2222de2668aaa366a1f0b560241eab128f3ca68e8add1", size = 3656166, upload-time = "2025-04-30T15:16:19.907Z" }, - { url = "https://files.pythonhosted.org/packages/b8/46/2dcae03a94f80f3e0d339c149de8110b5abe1230668b015fd338d9e71a27/selectolax-0.3.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:97b9971bb37b54ef4440134f22792d15c9ee12d890a526a7fe0b376502240143", size = 2095991, upload-time = "2025-04-30T15:16:21.654Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bd/95f15396e5f30898227d84a7ec6a39d9a9b34005f0e9f8f38e7fee21ab66/selectolax-0.3.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd99ff0f5a6c017c471635d4ee45b61d25f24689331e407147b2cf5e36892480", size = 5844493, upload-time = "2025-04-30T15:16:23.268Z" }, - { url = "https://files.pythonhosted.org/packages/36/25/64c60da9aec81f2992355b0a3ce00ea1ed99e6f5499868016d6972bd4948/selectolax-0.3.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8377c317bf1d5fd6ccc56dfb5a0928bbcbea3e800b7af54761cfbbb99dc94cb9", size = 5881062, upload-time = "2025-04-30T15:16:24.891Z" }, - { url = "https://files.pythonhosted.org/packages/b6/81/94105217f91f7c6a98ac3164210cba0c6aa8da91cb85405292a6d70e39c3/selectolax-0.3.29-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5388c56456272b2c241fc1906db9cc993984cafdad936cb5e061e3af0c44144e", size = 5470368, upload-time = "2025-04-30T15:16:26.457Z" }, - { url = "https://files.pythonhosted.org/packages/51/6e/40bc259f13e5d3dd0bb8ddd1d55ef099244db2568ffb82fd9d489984d61a/selectolax-0.3.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9e4690894f406863e25ba49da27e1a6fda9bfc21b0b315c399d3093be080e81", size = 5693476, upload-time = "2025-04-30T15:16:28.386Z" }, - { url = "https://files.pythonhosted.org/packages/58/bd/2668ee1d5471ad88daf83ca484515ba46774fc9c951d6c4c0beffea89952/selectolax-0.3.29-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:deeab93386b6c9a75052515f5b9e7e3dd623c585871c0c2b3126970ff902603b", size = 5449747, upload-time = "2025-04-30T15:16:30.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b5/1c61839ae5af70a8291c643982a99f051b543df90b220b98db1b26bd4899/selectolax-0.3.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6abdd8357f1c105c1add01a9f0373511fa832548b2e2778b00a8ba2a4508d6ed", size = 5786843, upload-time = "2025-04-30T15:16:32.231Z" }, - { url = "https://files.pythonhosted.org/packages/67/08/ca42c100ab90168c123e6b521e38cb7618b697a693fdb77e42dabb0670fd/selectolax-0.3.29-cp312-cp312-win32.whl", hash = "sha256:9c969626b2295702076f50aac91e44c3bba639fa2e1a612bf6ae254bf29b4d57", size = 1697859, upload-time = "2025-04-30T15:16:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/5c/22/9524af51d950cc718bd4406f3bed05acbfcb321a4a308ec85b96ccdaa1ef/selectolax-0.3.29-cp312-cp312-win_amd64.whl", hash = "sha256:e7f4cc1b7ce9691559decfd5db7cc500e71a9f6ccfe76c054f284c184a1d1dc9", size = 1804145, upload-time = "2025-04-30T15:16:35.12Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a7/083a00aa9cb6bef0317baba4269841c366652558d77189275bed2da6aa81/selectolax-0.3.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e3112f05a34bf36d36ecc51520b1d98c4667b54a3f123dffef5072273e89a360", size = 3651407, upload-time = "2025-04-30T15:16:37.282Z" }, - { url = "https://files.pythonhosted.org/packages/7e/cd/6c89ac27961ef5f5e9b40eda0d0653b9c95c93485fb8a554bf093eac1c77/selectolax-0.3.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38462ae369897f71da287f1282079c11f1b878b99a4d1d509d1116ce05226d88", size = 2092649, upload-time = "2025-04-30T15:16:38.817Z" }, - { url = "https://files.pythonhosted.org/packages/3e/12/82710124b7b52613fdb9d5c14494a41785eb83e1c93ec7e1d1814c2ce292/selectolax-0.3.29-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdd1e63735f2fb8485fb6b9f4fe30d6c030930f438f46a4a62bd9886ab3c7fd9", size = 5821738, upload-time = "2025-04-30T15:16:40.747Z" }, - { url = "https://files.pythonhosted.org/packages/8b/08/8ceb3eb7fee9743026a4481fccb771f257c82b2c853a1a30271902234eab/selectolax-0.3.29-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea52e0c128e8e89f98ab0ccaabbc853677de5730729a3351da595976131b66e0", size = 5856069, upload-time = "2025-04-30T15:16:42.496Z" }, - { url = "https://files.pythonhosted.org/packages/47/6c/ec2b7aff0f6202e4157415d76bd588108cc518374bf53afa81c122691780/selectolax-0.3.29-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0933659b4250b91317ccd78167e6804389cdaf7ed86c5d034b058a550d23110f", size = 5443255, upload-time = "2025-04-30T15:16:44.083Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/d5fea46ff191d02c2380a779b119ea6799751b79fcddb2bb230b21b38fc5/selectolax-0.3.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0c9005e9089a6b0c6fb6a9f691ddbbb10a3a23ebeff54393980340f3dbcdb99", size = 5637529, upload-time = "2025-04-30T15:16:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/9d/83/7f876a515f5af31f7b948cf10951be896fe6deeff2b9b713640c8ec82fd3/selectolax-0.3.29-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac940963c52f13cdf5d7266a979744949b660d367ce669efa073b557f6e09a18", size = 5379121, upload-time = "2025-04-30T15:16:47.909Z" }, - { url = "https://files.pythonhosted.org/packages/57/cb/7dc739a484b1a17ccf92a23dfe558ae615c232bd81e78a72049c25d1ff66/selectolax-0.3.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:484274f73839f9a143f4c13ce1b0a0123b5d64be22f967a1dc202a9a78687d67", size = 5727944, upload-time = "2025-04-30T15:16:49.52Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/95da4d2919d99a6090327390b84bc5440133196351e5e04c24cccda06cbb/selectolax-0.3.29-cp313-cp313-win32.whl", hash = "sha256:29e71fbd58b90d2920ef91a940680cb5331710fe397925ce9d10c3f2f086bf27", size = 1697529, upload-time = "2025-04-30T15:16:51.123Z" }, - { url = "https://files.pythonhosted.org/packages/0e/17/5a3951da22a4ad8f959088ddc370c68b28dad03190d91fcd137a52410fb9/selectolax-0.3.29-cp313-cp313-win_amd64.whl", hash = "sha256:e13befacff5f78102aa11465055ecb6d4b35f89663e36f271f2b506bcab14112", size = 1803334, upload-time = "2025-04-30T15:16:53.775Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "sse-starlette" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" }, -] - -[[package]] -name = "starlette" -version = "0.49.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, -] - -[[package]] -name = "tinycss2" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, -] - -[[package]] -name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, -] - -[[package]] -name = "trio" -version = "0.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/8f/c6e36dd11201e2a565977d8b13f0b027ba4593c1a80bed5185489178e257/trio-0.31.0.tar.gz", hash = "sha256:f71d551ccaa79d0cb73017a33ef3264fde8335728eb4c6391451fe5d253a9d5b", size = 605825, upload-time = "2025-09-09T15:17:15.242Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/5b/94237a3485620dbff9741df02ff6d8acaa5fdec67d81ab3f62e4d8511bf7/trio-0.31.0-py3-none-any.whl", hash = "sha256:b5d14cd6293d79298b49c3485ffd9c07e3ce03a6da8c7dfbe0cb3dd7dc9a4774", size = 512679, upload-time = "2025-09-09T15:17:13.821Z" }, -] - -[[package]] -name = "typer" -version = "0.17.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", -] -dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.35.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, -] - -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] diff --git a/src/agent_workflow/metrics.py b/src/agent_workflow/metrics.py index 8d89cbe..bb36530 100644 --- a/src/agent_workflow/metrics.py +++ b/src/agent_workflow/metrics.py @@ -37,14 +37,26 @@ def normalize_usage(usage: object) -> dict[str, Any]: ) output = _number(source.get("output_tokens", source.get("completion_tokens"))) provider_total = _number(source.get("total_tokens")) - cost = _number(source.get("cost", source.get("total_cost"))) + cache_write = _number( + source.get("cache_write_input_tokens", source.get("cache_creation_input_tokens")) + ) + reasoning = _number(source.get("reasoning_output_tokens", source.get("reasoning_tokens"))) + provider_cost = _number(source.get("provider_billed_cost")) + local_cost = _number(source.get("local_estimated_cost")) + legacy_cost = _number(source.get("cost", source.get("total_cost"))) currency = source.get("currency") if isinstance(source.get("currency"), str) else None + catalog = source.get("price_catalog_id") if isinstance(source.get("price_catalog_id"), str) else None return { "input_tokens": input_tokens, "cached_input_tokens": cached, + "cache_write_input_tokens": cache_write, "output_tokens": output, + "reasoning_output_tokens": reasoning, "provider_total_tokens": provider_total, - "cost": cost, + "provider_billed_cost": provider_cost, + "local_estimated_cost": local_cost, + "price_catalog_id": catalog, + "cost": legacy_cost, "currency": currency, } @@ -123,10 +135,22 @@ def build_execution_metrics(run_dir: Path, *, elapsed_seconds: float | None = No errors.append({"category": status.get("failure_category"), "detail": None}) orchestrator = _empty_stage("orchestrator") + workflow = provenance.get("workflow") + workflow_attempt = ( + workflow.get("attempt") if isinstance(workflow, dict) else None + ) + retry_count = ( + max(0, workflow_attempt - 1) + if isinstance(workflow_attempt, int) and not isinstance(workflow_attempt, bool) + else 1 + if provenance.get("retry_of_run_id") + else 0 + ) orchestrator.update({ **normalize_usage(provenance.get("usage")), "elapsed_seconds": round(elapsed_seconds, 6) if elapsed_seconds is not None else _number(status.get("wall_seconds")), "first_output_latency_seconds": _latency_seconds(provenance.get("started_at"), provenance.get("first_output_at")), + "retry_count": retry_count, "errors": errors, "steer_count": len(steer_ids), "steer_acknowledged_count": len(acked), diff --git a/src/agent_workflow/provider_evidence.py b/src/agent_workflow/provider_evidence.py new file mode 100644 index 0000000..c45ffd0 --- /dev/null +++ b/src/agent_workflow/provider_evidence.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import stat +from pathlib import Path +from typing import Any, Mapping + +from .contracts import validate_instance +from .errors import WorkflowError +from .util import atomic_write_json, utc_now + +PROVIDER_EVIDENCE_SCHEMA = "agent-workflow/provider-evidence/v1" +MAX_PROVIDER_EVENT_BYTES = 16 * 1024 * 1024 +USAGE_MODES = {"delta", "cumulative", "terminal"} + + +def _number(value: object) -> int | float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + if value < 0 or not math.isfinite(float(value)): + return None + return value + + +def _capture_raw_events(path: Path) -> tuple[bytes, int, str | None]: + """Read and hash one stable regular raw-event file without following symlinks.""" + try: + inspected = path.lstat() + except FileNotFoundError: + return b"", 0, None + except OSError as exc: + raise WorkflowError(f"cannot inspect provider event file {path}: {exc}") from exc + if stat.S_ISLNK(inspected.st_mode) or not stat.S_ISREG(inspected.st_mode): + raise WorkflowError(f"provider event file must be a regular non-symlink file: {path}") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open provider event file {path}: {exc}") from exc + captured = bytearray() + digest = hashlib.sha256() + total = 0 + with os.fdopen(descriptor, "rb") as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or (before.st_dev, before.st_ino) != ( + inspected.st_dev, + inspected.st_ino, + ): + raise WorkflowError(f"provider event file changed before capture: {path}") + while True: + chunk = stream.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + total += len(chunk) + remaining = MAX_PROVIDER_EVENT_BYTES + 1 - len(captured) + if remaining > 0: + captured.extend(chunk[:remaining]) + after = os.fstat(stream.fileno()) + if ( + before.st_size != after.st_size + or before.st_mtime_ns != after.st_mtime_ns + or before.st_ctime_ns != after.st_ctime_ns + or total != after.st_size + ): + raise WorkflowError(f"provider event file changed during capture: {path}") + return bytes(captured), total, digest.hexdigest() + + +def _usage_payload(event: Mapping[str, Any]) -> dict[str, Any] | None: + usage = event.get("usage") + if isinstance(usage, Mapping): + return dict(usage) + message = event.get("message") + if isinstance(message, Mapping) and isinstance(message.get("usage"), Mapping): + return dict(message["usage"]) + item = event.get("item") + if isinstance(item, Mapping) and isinstance(item.get("usage"), Mapping): + return dict(item["usage"]) + return None + + +def classify_usage_event( + event: Mapping[str, Any], stream_format: str +) -> tuple[dict[str, Any], str] | None: + """Classify only provider surfaces with an explicit or documented boundary.""" + usage = _usage_payload(event) + explicit = event.get("usage_mode", event.get("usage_type")) + if usage is not None: + embedded = usage.pop("mode", None) + mode = embedded if embedded in USAGE_MODES else explicit + if mode in USAGE_MODES: + return usage, str(mode) + event_type = str(event.get("type", "")) + if stream_format == "codex-jsonl" and event_type == "turn.completed": + return (usage or {}), "terminal" + if stream_format == "claude-stream-json": + if event_type == "result": + payload = usage or {} + billed = event.get("total_cost_usd") + if _number(billed) is not None: + payload["provider_billed_cost"] = billed + payload["currency"] = "USD" + return payload, "terminal" + if event_type == "assistant" and usage is not None: + # Claude Code assistant records correspond to individual Messages API + # responses; they are deltas within the enclosing CLI run. + return usage, "delta" + return None + + +def normalize_provider_usage(usage: Mapping[str, Any]) -> dict[str, Any]: + details = usage.get("input_tokens_details") + if not isinstance(details, Mapping): + details = usage.get("prompt_tokens_details") + if not isinstance(details, Mapping): + details = {} + output_details = usage.get("output_tokens_details") + if not isinstance(output_details, Mapping): + output_details = {} + + def first(*names: str) -> int | float | None: + for name in names: + if name in usage: + value = _number(usage[name]) + if value is not None: + return value + return None + + cached = first( + "cached_input_tokens", "cache_read_input_tokens", "cached_tokens" + ) + if cached is None: + cached = _number(details.get("cached_tokens")) + reasoning = first("reasoning_output_tokens", "reasoning_tokens") + if reasoning is None: + reasoning = _number(output_details.get("reasoning_tokens")) + billed = first("provider_billed_cost", "total_cost_usd") + legacy_cost = first("cost", "total_cost") + if billed is None and legacy_cost is not None and usage.get("cost_source") == "provider": + billed = legacy_cost + estimated = first("local_estimated_cost") + if estimated is None and legacy_cost is not None and usage.get("cost_source") == "local_estimate": + estimated = legacy_cost + currency = usage.get("currency") + return { + "input_tokens": first("input_tokens", "prompt_tokens"), + "cached_input_tokens": cached, + "cache_write_input_tokens": first( + "cache_write_input_tokens", "cache_creation_input_tokens" + ), + "output_tokens": first("output_tokens", "completion_tokens"), + "reasoning_output_tokens": reasoning, + "provider_total_tokens": first("total_tokens", "provider_total_tokens"), + "provider_billed_cost": billed, + "local_estimated_cost": estimated, + "currency": currency if isinstance(currency, str) and currency else None, + "price_catalog_id": ( + usage.get("price_catalog_id") + if isinstance(usage.get("price_catalog_id"), str) + and usage.get("price_catalog_id") + else None + ), + } + + +def _merge(records: list[dict[str, Any]]) -> tuple[dict[str, Any], bool, list[str]]: + modes = [str(item["mode"]) for item in records] + reasons: list[str] = [] + fields = ( + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "reasoning_output_tokens", + "provider_total_tokens", + "provider_billed_cost", + "local_estimated_cost", + ) + + def empty() -> dict[str, Any]: + aggregate = {name: None for name in fields} + aggregate.update(currency=None, price_catalog_id=None) + return aggregate + + def cost_metadata_complete(usage: Mapping[str, Any]) -> bool: + currency = usage.get("currency") + catalog = usage.get("price_catalog_id") + if usage.get("provider_billed_cost") is not None and currency is None: + reasons.append("PROVIDER_COST_MISSING_CURRENCY") + return False + if usage.get("local_estimated_cost") is not None and ( + currency is None or catalog is None + ): + reasons.append("LOCAL_ESTIMATE_MISSING_PRICE_METADATA") + return False + return True + + aggregate = empty() + if not records: + reasons.append("NO_CLASSIFIED_USAGE") + return aggregate, False, reasons + + terminals = [item for item in records if item["mode"] == "terminal"] + if terminals: + normalized = [item["usage"] for item in terminals] + if any(item != normalized[0] for item in normalized[1:]): + reasons.append("CONFLICTING_TERMINAL_UPDATES") + return empty(), False, reasons + selected = normalized[0] + aggregate.update(selected) + if len(terminals) > 1: + reasons.append("MULTIPLE_EQUIVALENT_TERMINAL_UPDATES") + if len(set(modes)) > 1: + reasons.append("MIXED_MODES_TERMINAL_AUTHORITATIVE") + if not any(_number(selected.get(field)) is not None for field in fields): + reasons.append("TERMINAL_USAGE_EMPTY") + return aggregate, False, reasons + return aggregate, cost_metadata_complete(aggregate), reasons + + unique_modes = set(modes) + if unique_modes == {"delta"}: + for field in fields: + values = [item["usage"].get(field) for item in records] + known = [value for value in values if _number(value) is not None] + aggregate[field] = sum(known) if known else None + currencies = { + item["usage"].get("currency") + for item in records + if item["usage"].get("currency") is not None + } + catalogs = { + item["usage"].get("price_catalog_id") + for item in records + if item["usage"].get("price_catalog_id") is not None + } + if len(currencies) > 1 or len(catalogs) > 1: + reasons.append("INCONSISTENT_DELTA_COST_METADATA") + return aggregate, False, reasons + aggregate["currency"] = next(iter(currencies), None) + aggregate["price_catalog_id"] = next(iter(catalogs), None) + if not any(_number(aggregate.get(field)) is not None for field in fields): + reasons.append("DELTA_USAGE_EMPTY") + return aggregate, False, reasons + return aggregate, cost_metadata_complete(aggregate), reasons + + if unique_modes == {"cumulative"}: + usages = [item["usage"] for item in records] + for field in fields: + previous: int | float | None = None + for usage in usages: + value = _number(usage.get(field)) + if value is None: + continue + if previous is not None and value < previous: + reasons.append("NONMONOTONIC_CUMULATIVE_USAGE") + return empty(), False, reasons + previous = value + currencies = {usage.get("currency") for usage in usages if usage.get("currency") is not None} + catalogs = { + usage.get("price_catalog_id") + for usage in usages + if usage.get("price_catalog_id") is not None + } + if len(currencies) > 1 or len(catalogs) > 1: + reasons.append("INCONSISTENT_CUMULATIVE_COST_METADATA") + return empty(), False, reasons + if not all(cost_metadata_complete(usage) for usage in usages): + return empty(), False, reasons + aggregate.update(usages[-1]) + if not any(_number(aggregate.get(field)) is not None for field in fields): + reasons.append("CUMULATIVE_USAGE_EMPTY") + return aggregate, False, reasons + return aggregate, True, reasons + + reasons.append("MIXED_NONTERMINAL_USAGE_MODES") + return aggregate, False, reasons + + +def build_provider_evidence( + *, + events_path: Path, + stream_format: str, + executor: str | None, + session_id: str, + retry_of: str | None = None, + capture_exceeded: bool = False, +) -> dict[str, Any]: + events_path = Path(events_path) + captured, raw_size, raw_events_sha256 = _capture_raw_events(events_path) + if raw_size > MAX_PROVIDER_EVENT_BYTES: + capture_exceeded = True + classified: list[dict[str, Any]] = [] + seen_event_ids: dict[str, str] = {} + seen_unidentified_raw: set[str] = set() + sequence = 0 + malformed = 0 + ambiguous_delta_duplicates = 0 + conflicting_event_ids = 0 + if captured: + if len(captured) > MAX_PROVIDER_EVENT_BYTES: + capture_exceeded = True + captured = captured[:MAX_PROVIDER_EVENT_BYTES] + for raw in captured.splitlines(): + if not raw: + continue + sequence += 1 + digest = hashlib.sha256(raw).hexdigest() + try: + event = json.loads(raw) + except json.JSONDecodeError: + malformed += 1 + continue + if not isinstance(event, Mapping): + malformed += 1 + continue + update = classify_usage_event(event, stream_format) + event_identity = event.get("event_id", event.get("id")) + if isinstance(event_identity, (str, int)) and not isinstance(event_identity, bool): + request_identity = event.get( + "request_id", event.get("response_id", event.get("turn_id", "")) + ) + identity = ( + f"{stream_format}:{event.get('type', 'unknown')}:" + f"{request_identity}:{event_identity}" + ) + prior_digest = seen_event_ids.get(identity) + if prior_digest is not None: + if prior_digest != digest: + conflicting_event_ids += 1 + continue + seen_event_ids[identity] = digest + else: + if digest in seen_unidentified_raw: + if update is not None and update[1] == "delta": + ambiguous_delta_duplicates += 1 + continue + seen_unidentified_raw.add(digest) + if update is None: + continue + usage, mode = update + classified.append( + { + "sequence": sequence, + "event_sha256": digest, + "event_type": str(event.get("type", "unknown")), + "mode": mode, + "usage": normalize_provider_usage(usage), + } + ) + aggregate, usage_complete, reasons = _merge(classified) + if ambiguous_delta_duplicates: + reasons.append("AMBIGUOUS_DUPLICATE_DELTA_EVENTS") + if conflicting_event_ids: + reasons.append("CONFLICTING_PROVIDER_EVENT_ID") + if malformed: + reasons.append("MALFORMED_RAW_EVENTS_PRESENT") + if capture_exceeded: + reasons.append("RAW_EVENT_CAPTURE_LIMIT_EXCEEDED") + evidence = { + "schema": PROVIDER_EVIDENCE_SCHEMA, + "session_id": session_id, + "executor": executor, + "stream_format": stream_format, + "created_at": utc_now(), + "raw_events_path": events_path.name, + "raw_events_sha256": raw_events_sha256, + "raw_event_bytes": raw_size, + "capture_limit_bytes": MAX_PROVIDER_EVENT_BYTES, + "capture_complete": not capture_exceeded, + "malformed_event_count": malformed, + "classified_usage_count": len(classified), + "retry_of_run_id": retry_of, + "usage_complete": ( + usage_complete + and not capture_exceeded + and malformed == 0 + and ambiguous_delta_duplicates == 0 + and conflicting_event_ids == 0 + ), + "incomplete_reasons": sorted(set(reasons)), + "usage_events": classified, + "aggregate": aggregate, + } + validate_instance(evidence, PROVIDER_EVIDENCE_SCHEMA, artifact="provider evidence") + return evidence + + +def write_provider_evidence( + run_dir: Path, + *, + capture_exceeded: bool = False, + stream_format: str | None = None, + executor: str | None = None, +) -> dict[str, Any]: + from .contracts import read_contract + + run_dir = Path(run_dir) + provenance = read_contract( + run_dir / "run-provenance.json", "agent-workflow/run-provenance/v1" + ) + evidence = build_provider_evidence( + events_path=run_dir / "executor-events.jsonl", + stream_format=stream_format or str(provenance.get("stream_format") or "text"), + executor=( + executor + if executor is not None + else (str(provenance["executor"]) if provenance.get("executor") else None) + ), + session_id=str(provenance["session_id"]), + retry_of=( + str(provenance["retry_of_run_id"]) + if provenance.get("retry_of_run_id") + else None + ), + capture_exceeded=capture_exceeded, + ) + path = run_dir / "provider-evidence.json" + atomic_write_json(path, evidence) + return evidence diff --git a/src/agent_workflow/receipts.py b/src/agent_workflow/receipts.py index df4dca6..e218c32 100644 --- a/src/agent_workflow/receipts.py +++ b/src/agent_workflow/receipts.py @@ -1,13 +1,17 @@ from __future__ import annotations +import fcntl +import hashlib import json import os +import stat +from contextlib import contextmanager from pathlib import Path -from typing import Any +from typing import Any, Iterator from .contracts import read_contract, validate_instance from .errors import WorkflowError -from .util import atomic_write_json, sha256_file, utc_now +from .util import atomic_write_json, fsync_directory, sha256_file, utc_now SEALED_ARTIFACTS = ( "prompt.md", @@ -34,11 +38,193 @@ "job-binding.json", "jobs/native-job.json", "agent-context.json", + "workflow-inputs.json", + "provider-evidence.json", "assignments.jsonl", ) SEALED_OPTIONAL_TREES = ("assignments",) +@contextmanager +def _seal_lock(run_dir: Path, *, exclusive: bool) -> Iterator[None]: + run_dir.mkdir(parents=True, exist_ok=True) + path = run_dir / "seal.lock" + existed = path.exists() or path.is_symlink() + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags, 0o600) + except OSError as exc: + raise WorkflowError(f"cannot open run seal lock {path}: {exc}") from exc + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"run seal lock must be a regular file: {path}") + if not existed: + os.fsync(descriptor) + fsync_directory(run_dir) + fcntl.flock(descriptor, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + try: + yield + finally: + fcntl.flock(descriptor, fcntl.LOCK_UN) + finally: + os.close(descriptor) + + +def _read_final_receipt(path: Path) -> tuple[dict[str, Any], str]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open final receipt {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"final receipt must be a regular file: {path}") + if info.st_mode & 0o222: + raise WorkflowError(f"final receipt must be read-only: {path}") + data = stream.read() + try: + receipt = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkflowError(f"cannot read final receipt {path}: {exc}") from exc + if not isinstance(receipt, dict) or not isinstance(receipt.get("artifacts"), list): + raise WorkflowError(f"invalid final receipt: {path}") + validate_instance(receipt, "agent-workflow/final-receipt/v1", artifact=str(path)) + return receipt, hashlib.sha256(data).hexdigest() + + +def _artifact_entry(receipt: dict[str, Any], relative_path: str) -> dict[str, Any]: + matches = [ + item + for item in receipt.get("artifacts", []) + if isinstance(item, dict) and item.get("path") == relative_path + ] + if len(matches) != 1: + raise WorkflowError( + f"final receipt must contain exactly one artifact entry for {relative_path}" + ) + return matches[0] + + +def _artifact_parts(relative_path: str) -> tuple[str, ...]: + relative = Path(relative_path) + parts = relative.parts + if ( + not parts + or relative.is_absolute() + or any(part in {"", ".", ".."} for part in parts) + or relative.as_posix() != relative_path + ): + raise WorkflowError(f"invalid sealed artifact path: {relative_path}") + return parts + + +def _open_artifact_beneath(run_dir: Path, relative_path: str) -> int: + """Open an artifact without following any path-component symlink.""" + parts = _artifact_parts(relative_path) + directory_flags = ( + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + current = os.open(run_dir.resolve(), directory_flags) + except OSError as exc: + raise WorkflowError(f"cannot open sealed run directory {run_dir}: {exc}") from exc + try: + for component in parts[:-1]: + try: + next_descriptor = os.open( + component, directory_flags, dir_fd=current + ) + except OSError as exc: + raise WorkflowError( + f"cannot open sealed artifact directory {relative_path}: {exc}" + ) from exc + os.close(current) + current = next_descriptor + try: + return os.open( + parts[-1], + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=current, + ) + except OSError as exc: + raise WorkflowError(f"cannot open sealed artifact {relative_path}: {exc}") from exc + finally: + os.close(current) + + +def _read_artifact_descriptor( + run_dir: Path, relative_path: str, *, capture_bytes: bool +) -> tuple[bytes | None, int, str]: + descriptor = _open_artifact_beneath(run_dir, relative_path) + digest = hashlib.sha256() + total = 0 + chunks: list[bytes] | None = [] if capture_bytes else None + with os.fdopen(descriptor, "rb") as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode): + raise WorkflowError( + f"sealed artifact must be a regular file: {relative_path}" + ) + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + total += len(chunk) + if chunks is not None: + chunks.append(chunk) + after = os.fstat(stream.fileno()) + if ( + before.st_size != after.st_size + or before.st_mtime_ns != after.st_mtime_ns + or before.st_ctime_ns != after.st_ctime_ns + or total != after.st_size + ): + raise WorkflowError(f"sealed artifact changed during read: {relative_path}") + return (b"".join(chunks) if chunks is not None else None), total, digest.hexdigest() + + +def read_sealed_artifact_bytes( + run_dir: Path, receipt: dict[str, Any], relative_path: str +) -> tuple[bytes, str]: + """Read one artifact through stable beneath-root descriptors and match its receipt.""" + entry = _artifact_entry(receipt, relative_path) + data, size, digest = _read_artifact_descriptor( + run_dir, relative_path, capture_bytes=True + ) + assert data is not None + if entry.get("size") != size: + raise WorkflowError(f"sealed artifact size mismatch: {relative_path}") + if entry.get("sha256") != digest: + raise WorkflowError(f"sealed artifact checksum mismatch: {relative_path}") + return data, digest + + +def read_sealed_json( + run_dir: Path, receipt: dict[str, Any], relative_path: str +) -> tuple[Any, str]: + data, digest = read_sealed_artifact_bytes(run_dir, receipt, relative_path) + try: + value = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkflowError(f"invalid sealed JSON artifact {relative_path}: {exc}") from exc + return value, digest + + +def read_sealed_contract( + run_dir: Path, + receipt: dict[str, Any], + relative_path: str, + schema: str, +) -> tuple[dict[str, Any], str]: + value, digest = read_sealed_json(run_dir, receipt, relative_path) + if not isinstance(value, dict): + raise WorkflowError(f"sealed contract must be a JSON object: {relative_path}") + validate_instance(value, schema, artifact=str(run_dir / relative_path)) + return value, digest + + def initial_completion( *, session_id: str, @@ -80,6 +266,7 @@ def initial_provenance( source_revision: str | None, worktree: Path, environment: dict[str, Any], + retry_of_run_id: str | None = None, budgets: dict[str, Any] | None = None, job_binding: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -104,9 +291,11 @@ def initial_provenance( "source_revision": source_revision, "worktree": str(worktree), "environment": environment, + "retry_of_run_id": retry_of_run_id, "budgets": budgets or {}, "job_binding": job_binding, "usage": None, + "provider_evidence": None, "started_at": utc_now(), "first_output_at": None, "finished_at": None, @@ -128,26 +317,26 @@ def update_provenance(run_dir: Path, **changes: Any) -> dict[str, Any]: def _artifact_receipt(path: Path, root: Path) -> dict[str, Any]: - if path.is_symlink(): - raise WorkflowError(f"sealed artifact must not be a symlink: {path}") - resolved = path.resolve() try: - rel = resolved.relative_to(root.resolve()) + relative_path = path.relative_to(root).as_posix() except ValueError as exc: raise WorkflowError(f"sealed artifact escapes run root: {path}") from exc + _, size, digest = _read_artifact_descriptor( + root, relative_path, capture_bytes=False + ) return { - "path": rel.as_posix(), - "size": path.stat().st_size, - "sha256": sha256_file(path), + "path": relative_path, + "size": size, + "sha256": digest, } -def seal_run(run_dir: Path, *, session_id: str) -> dict[str, Any]: +def _seal_run_unlocked(run_dir: Path, *, session_id: str) -> dict[str, Any]: run_dir = run_dir.resolve() final_receipt = run_dir / "final-receipt.json" - if final_receipt.exists(): + if final_receipt.exists() or final_receipt.is_symlink(): raise WorkflowError( - f"run is already sealed: {final_receipt}; verify it with the recorded checksum" + f"run is already sealed or unsafe: {final_receipt}; verify it with the recorded checksum" ) artifacts = [ _artifact_receipt(path, run_dir) @@ -200,6 +389,9 @@ def seal_run(run_dir: Path, *, session_id: str) -> dict[str, Any]: metrics = run_dir / "execution-metrics.json" if metrics.is_file(): read_contract(metrics, "agent-workflow/execution-metrics/v1") + provider_evidence = run_dir / "provider-evidence.json" + if provider_evidence.is_file(): + read_contract(provider_evidence, "agent-workflow/provider-evidence/v1") controls = run_dir / "control-events.jsonl" if controls.is_file(): for line_number, raw in enumerate(controls.read_text(encoding="utf-8").splitlines(), start=1): @@ -216,32 +408,28 @@ def seal_run(run_dir: Path, *, session_id: str) -> dict[str, Any]: "sealed_at": utc_now(), "artifacts": artifacts, } - atomic_write_json(final_receipt, receipt) validate_instance( receipt, "agent-workflow/final-receipt/v1", artifact=str(final_receipt), ) - final_receipt.chmod(0o444) + atomic_write_json(final_receipt, receipt, mode=0o444) return receipt -def verify_seal( - run_dir: Path, *, expected_sha256: str -) -> dict[str, Any]: +def seal_run(run_dir: Path, *, session_id: str) -> dict[str, Any]: + run_dir = run_dir.resolve() + with _seal_lock(run_dir, exclusive=True): + return _seal_run_unlocked(run_dir, session_id=session_id) + + +def _verify_seal_unlocked( + run_dir: Path, *, expected_sha256: str | None = None +) -> tuple[dict[str, Any], str]: run_dir = run_dir.resolve() path = run_dir / "final-receipt.json" - try: - receipt = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise WorkflowError(f"cannot read final receipt {path}: {exc}") from exc - if not isinstance(receipt, dict) or not isinstance(receipt.get("artifacts"), list): - raise WorkflowError(f"invalid final receipt: {path}") - validate_instance( - receipt, "agent-workflow/final-receipt/v1", artifact=str(path) - ) - actual = sha256_file(path) - if actual != expected_sha256: + receipt, actual = _read_final_receipt(path) + if expected_sha256 is not None and actual != expected_sha256: raise WorkflowError( f"final receipt checksum mismatch: {actual}; expected {expected_sha256}" ) @@ -256,32 +444,78 @@ def verify_seal( for item in receipt["artifacts"]: if not isinstance(item, dict) or not isinstance(item.get("path"), str): raise WorkflowError(f"invalid artifact entry in {path}") - artifact = run_dir / item["path"] - try: - artifact.resolve().relative_to(run_dir) - except ValueError as exc: - raise WorkflowError(f"receipt artifact escapes run root: {artifact}") from exc - if not artifact.is_file() or artifact.is_symlink(): - raise WorkflowError(f"sealed artifact missing or unsafe: {artifact}") - if artifact.stat().st_size != item.get("size"): - raise WorkflowError(f"sealed artifact size mismatch: {artifact}") - if sha256_file(artifact) != item.get("sha256"): - raise WorkflowError(f"sealed artifact checksum mismatch: {artifact}") + relative_path = item["path"] + _artifact_parts(relative_path) + _, size, digest = _read_artifact_descriptor( + run_dir, relative_path, capture_bytes=False + ) + if size != item.get("size"): + raise WorkflowError(f"sealed artifact size mismatch: {relative_path}") + if digest != item.get("sha256"): + raise WorkflowError(f"sealed artifact checksum mismatch: {relative_path}") + return receipt, actual + + +def verify_seal_details( + run_dir: Path, *, expected_sha256: str | None = None +) -> tuple[dict[str, Any], str]: + """Verify one sealed run and return the receipt plus its verified-byte digest.""" + run_dir = run_dir.resolve() + with _seal_lock(run_dir, exclusive=False): + return _verify_seal_unlocked(run_dir, expected_sha256=expected_sha256) + + +def verify_seal( + run_dir: Path, *, expected_sha256: str | None = None +) -> dict[str, Any]: + receipt, _ = verify_seal_details(run_dir, expected_sha256=expected_sha256) return receipt def final_receipt_sha256(run_dir: Path) -> str: - return sha256_file(run_dir / "final-receipt.json") + run_dir = run_dir.resolve() + with _seal_lock(run_dir, exclusive=False): + _, digest = _read_final_receipt(run_dir / "final-receipt.json") + return digest + + +def _make_file_read_only(path: Path) -> None: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open sealed artifact for chmod {path}: {exc}") from exc + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"sealed artifact must be a regular file: {path}") + os.fchmod(descriptor, info.st_mode & ~0o222) + os.fsync(descriptor) + finally: + os.close(descriptor) def make_read_only(run_dir: Path) -> None: for name in (*SEALED_ARTIFACTS, *SEALED_OPTIONAL_ARTIFACTS): path = run_dir / name - if path.is_file(): - os.chmod(path, path.stat().st_mode & ~0o222) - for tree in SEALED_TREES: + if path.exists() or path.is_symlink(): + _make_file_read_only(path) + for tree in (*SEALED_TREES, *SEALED_OPTIONAL_TREES): tree_root = run_dir / tree - if tree_root.is_dir(): - for path in tree_root.rglob("*"): - if path.is_file(): - os.chmod(path, path.stat().st_mode & ~0o222) + if not tree_root.exists() and not tree_root.is_symlink(): + continue + try: + root_info = tree_root.lstat() + except OSError as exc: + raise WorkflowError(f"cannot inspect sealed artifact tree {tree_root}: {exc}") from exc + if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode): + raise WorkflowError(f"sealed artifact tree is unsafe: {tree_root}") + for path in tree_root.rglob("*"): + try: + info = path.lstat() + except OSError as exc: + raise WorkflowError(f"cannot inspect sealed artifact {path}: {exc}") from exc + if stat.S_ISLNK(info.st_mode): + raise WorkflowError(f"sealed artifact must not be a symlink: {path}") + if stat.S_ISREG(info.st_mode): + _make_file_read_only(path) diff --git a/src/agent_workflow/routing.py b/src/agent_workflow/routing.py new file mode 100644 index 0000000..3dd51af --- /dev/null +++ b/src/agent_workflow/routing.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import fnmatch +from typing import Any, Mapping + +from .config import Settings +from .errors import WorkflowError + +ROUTING_ADVICE_SCHEMA = "agent-workflow/routing-advice/v1" + + +def _safe_model(settings: Settings, agent_class: str, executor: str) -> str: + class_policy = settings.agent_classes.get(agent_class) + if class_policy is None: + raise WorkflowError(f"unknown routing agent class: {agent_class}") + candidates = list(class_policy.allowed_models.get(executor, ())) + if class_policy.default_executor == executor and class_policy.default_model in candidates: + candidates.remove(class_policy.default_model) + candidates.insert(0, class_policy.default_model) + executor_policy = settings.executor_policies.get(executor) + for model in candidates: + if executor_policy and any( + fnmatch.fnmatchcase(model, pattern) + for pattern in executor_policy.no_go_models + ): + continue + return model + raise WorkflowError( + f"routing policy has no non-no-go model for class {agent_class!r} " + f"and executor {executor!r}" + ) + + +def advise_routing( + metadata: Mapping[str, Any] | None, + settings: Settings, + *, + enforced_selection: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Return deterministic advice; configuration/session enforcement stays authoritative.""" + metadata = dict(metadata or {}) + task_type = str(metadata.get("task_type", "implementation")).strip().lower() + risk = str(metadata.get("risk", "normal")).strip().lower() + codes: list[str] = [] + if task_type in {"research", "discovery", "exploratory", "spike"}: + agent_class = "exploratory" + codes.append("TASK_EXPLORATORY") + elif task_type in {"review", "audit", "security", "verification"}: + agent_class = "review" + codes.append("TASK_REVIEW") + else: + agent_class = "implementation" + codes.append("TASK_IMPLEMENTATION") + if risk in {"high", "critical"} and agent_class == "implementation": + codes.append("RISK_HIGH_IMPLEMENTATION") + class_policy = settings.agent_classes.get(agent_class) + if class_policy is None: + raise WorkflowError(f"routing class is not configured: {agent_class}") + executor = class_policy.default_executor + model = _safe_model(settings, agent_class, executor) + interactive = class_policy.interactive + if metadata.get("requires_interaction") is True: + interactive = True + codes.append("INTERACTION_REQUIRED") + elif metadata.get("requires_interaction") is False: + interactive = False + codes.append("INTERACTION_NOT_REQUIRED") + recommendation = { + "agent_class": agent_class, + "executor": executor, + "model": model, + "interactive": interactive, + } + enforced = dict(recommendation) + for key in recommendation: + if enforced_selection and enforced_selection.get(key) is not None: + enforced[key] = enforced_selection[key] + disagreements = sorted( + key for key in recommendation if recommendation[key] != enforced[key] + ) + if disagreements: + codes.append("ENFORCED_SELECTION_DIFFERS") + return { + "schema": ROUTING_ADVICE_SCHEMA, + "recommendation": recommendation, + "explanation_codes": sorted(set(codes)), + "enforced_selection": enforced, + "policy_disagreements": disagreements, + } diff --git a/src/agent_workflow/runner.py b/src/agent_workflow/runner.py index 616bd2c..dd78f8d 100644 --- a/src/agent_workflow/runner.py +++ b/src/agent_workflow/runner.py @@ -19,8 +19,9 @@ from .eval.commands import collect_commands, specs_from_data from .eval.scope import ScopePolicy, collect_scope from .executors import accumulate_usage, event_text, parse_event, usage_update -from .receipts import make_read_only, seal_run, update_provenance +from .receipts import final_receipt_sha256, make_read_only, seal_run, update_provenance from .metrics import write_execution_evidence +from .provider_evidence import MAX_PROVIDER_EVENT_BYTES, write_provider_evidence from .util import atomic_write_json, sha256_file, utc_now @@ -352,6 +353,8 @@ def execute( heartbeat_path = run_dir / "heartbeat.json" lock = threading.Lock() usage: dict[str, Any] | None = None + provider_event_bytes = 0 + provider_capture_exceeded = False first_output_at: str | None = None last_normalized_text: str | None = None pump_errors: list[str] = [] @@ -416,9 +419,22 @@ def execute( "updated_at": finished_at, } atomic_write_json(run_dir / "final-status.json", final_status) + provider = write_provider_evidence( + run_dir, stream_format=stream_format, executor=provenance_initial.get("executor") + ) + update_provenance( + run_dir, + provider_evidence={ + "path": "provider-evidence.json", + "sha256": sha256_file(run_dir / "provider-evidence.json"), + "usage_complete": provider["usage_complete"], + "capture_complete": provider["capture_complete"], + }, + usage=provider["aggregate"], + ) write_execution_evidence(run_dir, elapsed_seconds=time.monotonic() - wall_started) receipt = seal_run(run_dir, session_id=str(current["session_id"])) - receipt_hash = sha256_file(run_dir / "final-receipt.json") + receipt_hash = final_receipt_sha256(run_dir) _update_status( status_path, **final_status, @@ -446,7 +462,8 @@ def forward_signal(signum: int, _frame: Any) -> None: signal.signal(signal.SIGTERM, forward_signal) def stdout_pump() -> None: - nonlocal usage, first_output_at, last_normalized_text + nonlocal usage, provider_event_bytes, provider_capture_exceeded + nonlocal first_output_at, last_normalized_text try: with output_path.open("ab") as output, events_path.open("ab") as events: for raw in iter(process.stdout.readline, b""): @@ -457,7 +474,11 @@ def stdout_pump() -> None: _write_bytes(output, raw) _mirror_terminal(sys.stdout.buffer, raw) else: - _write_bytes(events, raw) + if provider_event_bytes + len(raw) <= MAX_PROVIDER_EVENT_BYTES: + _write_bytes(events, raw) + provider_event_bytes += len(raw) + else: + provider_capture_exceeded = True line = raw.decode("utf-8", errors="replace").rstrip("\r\n") event = parse_event(line, stream_format) if event is None: @@ -586,6 +607,21 @@ def stderr_pump() -> None: pump_errors.append(f"collectors: {exc}") return_code = return_code or 1 + provider = write_provider_evidence( + run_dir, + capture_exceeded=provider_capture_exceeded, + stream_format=stream_format, + executor=( + str(provenance_initial["executor"]) + if provenance_initial.get("executor") + else None + ), + ) + usage = provider["aggregate"] + if provider_capture_exceeded: + pump_errors.append("provider evidence capture limit exceeded") + return_code = return_code or 1 + terminal_status = ( "completed" if return_code == 0 @@ -605,7 +641,11 @@ def stderr_pump() -> None: if isinstance(used, (int, float)) and isinstance(limit, (int, float)): if used > limit: budget_exceeded.append(f"{usage_key}:{used}>{limit}") - cost = usage.get("cost", usage.get("total_cost")) + cost = usage.get("provider_billed_cost") + if cost is None: + cost = usage.get("local_estimated_cost") + if cost is None: + cost = usage.get("cost", usage.get("total_cost")) max_cost = budgets.get("max_cost") if isinstance(cost, (int, float)) and isinstance(max_cost, (int, float)): if cost > max_cost: @@ -637,6 +677,12 @@ def stderr_pump() -> None: finished_at=finished_at, exit_code=return_code, usage=usage, + provider_evidence={ + "path": "provider-evidence.json", + "sha256": sha256_file(run_dir / "provider-evidence.json"), + "usage_complete": provider["usage_complete"], + "capture_complete": provider["capture_complete"], + }, ) current = _read_status(status_path) final_status = { @@ -665,7 +711,7 @@ def stderr_pump() -> None: write_execution_evidence(run_dir, elapsed_seconds=wall_seconds) try: receipt = seal_run(run_dir, session_id=str(current["session_id"])) - receipt_hash = sha256_file(run_dir / "final-receipt.json") + receipt_hash = final_receipt_sha256(run_dir) _update_status( status_path, **{ diff --git a/src/agent_workflow/scheduler.py b/src/agent_workflow/scheduler.py new file mode 100644 index 0000000..c2be6ab --- /dev/null +++ b/src/agent_workflow/scheduler.py @@ -0,0 +1,627 @@ +from __future__ import annotations + +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping + +from .config import Settings +from .approval import lifecycle_disposition +from .bindings import resolve_node_inputs +from .contracts import read_contract, validate_instance +from .errors import WorkflowError +from .receipts import ( + read_sealed_contract, + update_provenance, + verify_seal, + verify_seal_details, +) +from .routing import advise_routing +from .sessions import launch as launch_session +from .util import validate_id +from .workflow import ( + ensure_workflow_events_file, + reconstruct_workflow_status, + record_workflow_binding, + record_workflow_transition, + snapshot_sha256, + workflow_events_path, + workflow_lock, +) +from .state import run_dir as session_run_dir + + +LaunchFunction = Callable[[Mapping[str, Any], str], Any] + + +@dataclass(frozen=True) +class LaunchPlan: + """A durable node binding selected for one scheduler attempt.""" + + node_id: str + run_id: str + attempt: int + retry_of_run_id: str | None = None + + +def calculate_eligibility( + snapshot: Mapping[str, Any], status: Mapping[str, Any] +) -> list[str]: + """Return nodes that may be launched, in deterministic graph order. + + Failed prerequisites intentionally leave their dependents blocked. A + retry must first make the prerequisite successful; there is no implicit + retry policy in this service. + """ + nodes = {str(node["node_id"]): node for node in snapshot["nodes"]} + states = {str(node["node_id"]): str(node["state"]) for node in status["nodes"]} + return sorted( + node_id + for node_id, node in nodes.items() + if states.get(node_id) == "eligible" + and all(states.get(dep) == "completed" for dep in node["dependencies"]) + ) + + +def plan_launches( + snapshot: Mapping[str, Any], + status: Mapping[str, Any], + *, + max_parallelism: int, +) -> list[str]: + """Select eligible task nodes without exceeding total active capacity.""" + if isinstance(max_parallelism, bool) or max_parallelism < 1: + raise WorkflowError("max_parallelism must be a positive integer") + running = sum( + 1 + for node in status["nodes"] + if str(node.get("state")) == "running" + ) + capacity = max(0, max_parallelism - running) + if capacity == 0: + return [] + node_map = {str(node["node_id"]): node for node in snapshot["nodes"]} + return [ + node_id + for node_id in calculate_eligibility(snapshot, status) + if node_map[node_id].get("kind", "task") == "task" + ][:capacity] + + +class SchedulerService: + """Restart-safe dependency scheduler backed by the workflow event journal.""" + + def __init__( + self, + *, + settings: Settings, + run_dir: Path, + workdir: Path, + max_parallelism: int = 1, + launch_fn: LaunchFunction | None = None, + actor: str = "scheduler", + ) -> None: + if isinstance(max_parallelism, bool) or max_parallelism < 1: + raise WorkflowError("max_parallelism must be a positive integer") + self.settings = settings + self.run_dir = Path(run_dir) + self.workdir = Path(workdir) + self.max_parallelism = max_parallelism + self.launch_fn = launch_fn or self._launch + self.actor = actor + + def _launch(self, node: Mapping[str, Any], run_id: str) -> Any: + prompt = Path(str(node["prompt_path"])) + if not prompt.is_absolute(): + prompt = self.workdir / prompt + explicit = { + key: node.get(key) + for key in ("agent_class", "executor", "model", "interactive") + if node.get(key) is not None + } + advice = advise_routing( + node.get("routing") if isinstance(node.get("routing"), Mapping) else {}, + self.settings, + enforced_selection=explicit, + ) + validate_instance(advice, advice["schema"], artifact="workflow routing advice") + selected = advice["enforced_selection"] + result = launch_session( + self.settings, + session_id=run_id, + workdir=self.workdir, + prompt_path=prompt, + ticket_id=str(node["ticket_id"]) if node.get("ticket_id") else None, + pack_id=str(node["pack_id"]) if node.get("pack_id") else None, + retry_of=str(node["retry_of_run_id"]) if node.get("retry_of_run_id") else None, + tier=str(node["tier"]) if node.get("tier") else None, + executor=str(selected["executor"]), + agent_class=str(selected["agent_class"]), + model=str(selected["model"]), + interactive=bool(selected["interactive"]), + allow_no_go_model=bool(node.get("allow_no_go_model", False)), + workflow_context=( + node["workflow_inputs"]["artifact"] + if isinstance(node.get("workflow_inputs"), Mapping) + else None + ), + ) + child_dir = session_run_dir(self.settings, run_id) + command = read_contract(child_dir / "command.json", "agent-workflow/command/v1") + actual = { + "agent_class": command.get("agent_class"), + "executor": command.get("executor"), + "model": command.get("model"), + "interactive": command.get("interactive"), + } + routing_record = dict(advice) + routing_record["actual_selection"] = actual + routing_record["policy_disagreements"] = sorted( + key + for key in advice["recommendation"] + if advice["recommendation"][key] != actual.get(key) + ) + provenance = read_contract( + child_dir / "run-provenance.json", "agent-workflow/run-provenance/v1" + ) + workflow = dict(provenance.get("workflow") or {}) + workflow.setdefault("workflow_id", str(node["workflow_id"])) + workflow.setdefault("node_id", str(node["node_id"])) + workflow.setdefault("attempt", int(node["workflow_attempt"])) + workflow.setdefault("inputs_path", None) + workflow.setdefault("inputs_sha256", None) + workflow["routing"] = routing_record + update_provenance(child_dir, workflow=workflow) + return result + + @staticmethod + def _run_id(workflow_id: str, node: Mapping[str, Any], attempt: int) -> str: + base = str(node["session_id"]) + run_id = base if attempt == 1 else f"{base}-retry-{attempt}" + return validate_id(run_id, "workflow run ID") + + def status(self, snapshot: Mapping[str, Any]) -> dict[str, Any]: + events_path = ensure_workflow_events_file(self.run_dir) + return reconstruct_workflow_status(snapshot, events_path) + + def _child_run_exists(self, run_id: str) -> bool: + """Return whether a child has a real run footprint outside workflow events. + + The workflow journal may record that a launch once existed, but it cannot + prove that the child evidence still exists. Mutable ``status.json`` is + also only a projection. A matching provenance contract is the minimum + durable launch footprint; a valid final receipt is stronger evidence. + """ + child = session_run_dir(self.settings, run_id) + try: + info = child.lstat() + except OSError: + return False + if child.is_symlink() or not child.is_dir(): + return False + final_receipt = child / "final-receipt.json" + if final_receipt.exists() or final_receipt.is_symlink(): + try: + receipt = verify_seal(child) + except WorkflowError: + return False + return receipt.get("session_id") == run_id + provenance_path = child / "run-provenance.json" + if provenance_path.is_symlink() or not provenance_path.is_file(): + return False + try: + provenance = read_contract( + provenance_path, "agent-workflow/run-provenance/v1" + ) + except WorkflowError: + return False + return provenance.get("session_id") == run_id + + def _terminal_child_outcome(self, run_id: str) -> tuple[str, str, dict[str, Any]] | None: + """Return a verified terminal workflow transition for a sealed child.""" + child = session_run_dir(self.settings, run_id) + receipt_path = child / "final-receipt.json" + if not receipt_path.exists() and not receipt_path.is_symlink(): + return None + receipt, receipt_digest = verify_seal_details(child) + if receipt.get("session_id") != run_id: + raise WorkflowError(f"child final receipt belongs to another run: {run_id}") + final_status, _ = read_sealed_contract( + child, + receipt, + "final-status.json", + "agent-workflow/session-status/v2", + ) + completion, completion_digest = read_sealed_contract( + child, + receipt, + "completion.json", + "agent-workflow/completion/v1", + ) + collection, _ = read_sealed_contract( + child, + receipt, + "collections/completion.json", + "agent-workflow/completion-collection/v1", + ) + if final_status.get("session_id") != run_id or completion.get("session_id") != run_id: + raise WorkflowError(f"child terminal evidence belongs to another run: {run_id}") + completed = ( + final_status.get("status") == "completed" + and completion.get("result") == "completed" + and collection.get("validation_status") == "valid" + ) + details = { + "child_run_id": run_id, + "child_final_receipt_sha256": receipt_digest, + "child_completion_sha256": completion_digest, + "child_status": final_status.get("status"), + "child_completion_result": completion.get("result"), + "child_completion_validation_status": collection.get("validation_status"), + } + if completed: + return "completed", "sealed child run completed successfully", details + return "failed", "sealed child run did not satisfy completion evidence", details + + def _reconcile_running(self, snapshot: Mapping[str, Any], status: Mapping[str, Any]) -> None: + digest = snapshot_sha256(snapshot) + node_map = {str(node["node_id"]): node for node in snapshot["nodes"]} + for current in status["nodes"]: + if current.get("state") != "running": + continue + node_id = str(current["node_id"]) + if node_map[node_id].get("kind", "task") != "task": + raise WorkflowError(f"approval node cannot be running: {node_id}") + run_id = current.get("run_id") + if not isinstance(run_id, str) or not run_id: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason="running node has no authoritative child run binding", + snapshot_sha256=digest, + previous_state="running", + next_state="recoverable", + ) + continue + try: + outcome = self._terminal_child_outcome(run_id) + except WorkflowError as exc: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason=f"child terminal evidence failed verification: {exc}", + snapshot_sha256=digest, + previous_state="running", + next_state="failed", + details={"child_run_id": run_id}, + ) + continue + if outcome is not None: + next_state, reason, details = outcome + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason=reason, + snapshot_sha256=digest, + previous_state="running", + next_state=next_state, + details=details, + ) + elif not self._child_run_exists(run_id): + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason="running state has no authoritative child run; recovery required", + snapshot_sha256=digest, + previous_state="running", + next_state="recoverable", + details={"child_run_id": run_id}, + ) + + def _reconcile_blocked(self, snapshot: Mapping[str, Any], status: Mapping[str, Any]) -> None: + status_map = {str(item["node_id"]): item for item in status["nodes"]} + states = {node_id: str(item["state"]) for node_id, item in status_map.items()} + digest = snapshot_sha256(snapshot) + for node in snapshot["nodes"]: + node_id = str(node["node_id"]) + current = status_map[node_id] + failed_dependencies = [ + dep for dep in node["dependencies"] if states.get(dep) == "failed" + ] + dependency_failure = str(current.get("terminal_reason") or "").startswith( + "workflow prerequisite failed:" + ) + if states.get(node_id) == "failed" and dependency_failure and not failed_dependencies: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason="workflow prerequisite retry reopened dependency gate", + snapshot_sha256=digest, + previous_state="failed", + next_state="blocked", + ) + states[node_id] = "blocked" + if states.get(node_id) != "blocked": + continue + failed_dependencies = [ + dep for dep in node["dependencies"] if states.get(dep) == "failed" + ] + if failed_dependencies: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason=( + "workflow prerequisite failed: " + + ", ".join(sorted(failed_dependencies)) + ), + snapshot_sha256=digest, + previous_state="blocked", + next_state="failed", + details={"failed_dependencies": sorted(failed_dependencies)}, + ) + states[node_id] = "failed" + elif all(states.get(dep) == "completed" for dep in node["dependencies"]): + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason="all workflow prerequisites completed", + snapshot_sha256=digest, + previous_state="blocked", + next_state="eligible", + ) + states[node_id] = "eligible" + + def _reconcile_approvals( + self, snapshot: Mapping[str, Any], status: Mapping[str, Any] + ) -> None: + node_map = {str(node["node_id"]): node for node in snapshot["nodes"]} + status_map = {str(item["node_id"]): item for item in status["nodes"]} + digest = snapshot_sha256(snapshot) + for node_id in calculate_eligibility(snapshot, status): + node = node_map[node_id] + if node.get("kind", "task") != "approval": + continue + subject_id = str(node["approval_for"]) + subject = status_map[subject_id] + run_id = subject.get("run_id") + if not isinstance(run_id, str) or not run_id: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason="approval subject has no authoritative child run binding", + snapshot_sha256=digest, + previous_state="eligible", + next_state="failed", + details={"approval_for": subject_id}, + ) + continue + try: + disposition = lifecycle_disposition( + session_run_dir(self.settings, run_id) + ) + except WorkflowError as exc: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason=f"approval evidence failed verification: {exc}", + snapshot_sha256=digest, + previous_state="eligible", + next_state="failed", + details={"approval_for": subject_id, "subject_run_id": run_id}, + ) + continue + if disposition is None or disposition["action"] == "reviewed": + continue + action = str(disposition["action"]) + details = { + "approval_for": subject_id, + "subject_run_id": run_id, + "approval_action": action, + "approval_receipt_sha256": disposition["receipt_sha256"], + "final_receipt_sha256": disposition["final_receipt_sha256"], + "completion_sha256": disposition["completion_sha256"], + "revision": disposition["revision"], + } + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=node_id, + actor=self.actor, + reason=( + "canonical lifecycle receipt accepted" + if action == "accepted" + else "canonical lifecycle receipt rejected" + ), + snapshot_sha256=digest, + previous_state="eligible", + next_state="completed" if action == "accepted" else "failed", + details=details, + ) + + def _bind(self, snapshot: Mapping[str, Any], node: Mapping[str, Any], *, retry: bool) -> LaunchPlan: + status = self.status(snapshot) + current = next(item for item in status["nodes"] if item["node_id"] == node["node_id"]) + digest = snapshot_sha256(snapshot) + # A process may have persisted the binding and exited before its + # running transition. Reuse that binding instead of creating a second + # launch lineage during replay. + if ( + not retry + and current["state"] == "eligible" + and current["run_id"] is not None + ): + return LaunchPlan( + str(node["node_id"]), + str(current["run_id"]), + int(current["attempt"]), + current["retry_of_run_id"], + ) + if retry: + if current["state"] not in {"failed", "recoverable"}: + raise WorkflowError(f"node {node['node_id']} is not retryable") + attempt = int(current["attempt"] or 0) + 1 + retry_of = str(current["run_id"]) + else: + if current["state"] != "eligible": + raise WorkflowError(f"node {node['node_id']} is not eligible") + attempt = 1 + retry_of = None + run_id = self._run_id(str(snapshot["workflow_id"]), node, attempt) + record_workflow_binding( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=str(node["node_id"]), + run_id=run_id, + attempt=attempt, + retry_of_run_id=retry_of, + actor=self.actor, + reason="scheduler launch binding", + snapshot_sha256=digest, + ) + if retry: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=str(node["node_id"]), + actor=self.actor, + reason="retry binding is eligible", + snapshot_sha256=digest, + previous_state=str(current["state"]), + next_state="eligible", + ) + return LaunchPlan(str(node["node_id"]), run_id, attempt, retry_of) + + def _execute(self, snapshot: Mapping[str, Any], node: Mapping[str, Any], plan: LaunchPlan) -> Any: + node_with_lineage = dict(node) + node_with_lineage["pack_id"] = str(node.get("pack_id") or snapshot["pack_id"]) + node_with_lineage["workflow_id"] = str(snapshot["workflow_id"]) + node_with_lineage["workflow_attempt"] = plan.attempt + node_with_lineage["retry_of_run_id"] = plan.retry_of_run_id + status = self.status(snapshot) + try: + resolved_inputs = resolve_node_inputs( + snapshot=snapshot, + status=status, + node=node, + settings=self.settings, + workflow_run_dir=self.run_dir, + attempt=plan.attempt, + ) + except WorkflowError as exc: + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=plan.node_id, + actor=self.actor, + reason=f"workflow input binding failed: {exc}", + snapshot_sha256=snapshot_sha256(snapshot), + previous_state="eligible", + next_state="failed", + ) + raise + if resolved_inputs is not None: + node_with_lineage["workflow_inputs"] = resolved_inputs + if self._child_run_exists(plan.run_id): + return {"recovered": True, "run_id": plan.run_id} + try: + result = self.launch_fn(node_with_lineage, plan.run_id) + except Exception as exc: + next_state = "recoverable" if self._child_run_exists(plan.run_id) else "failed" + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=plan.node_id, + actor=self.actor, + reason=f"launch failed; recovery state required: {exc}", + snapshot_sha256=snapshot_sha256(snapshot), + previous_state="eligible", + next_state=next_state, + ) + raise + if not self._child_run_exists(plan.run_id): + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=plan.node_id, + actor=self.actor, + reason="launch outcome is not durably authoritative; manual recovery required", + snapshot_sha256=snapshot_sha256(snapshot), + previous_state="eligible", + next_state="recoverable", + ) + raise WorkflowError(f"launch {plan.run_id} has no authoritative child run") + record_workflow_transition( + self.run_dir, + workflow_id=str(snapshot["workflow_id"]), + node_id=plan.node_id, + actor=self.actor, + reason="authoritative child run exists", + snapshot_sha256=snapshot_sha256(snapshot), + previous_state="eligible", + next_state="running", + details={ + "child_run_id": plan.run_id, + "input_binding_sha256": ( + resolved_inputs["sha256"] if resolved_inputs is not None else None + ), + }, + ) + return result + + def launch_eligible(self, snapshot: Mapping[str, Any]) -> dict[str, Any]: + with workflow_lock(self.run_dir): + normalized_status = self.status(snapshot) + self._reconcile_running(snapshot, normalized_status) + normalized_status = self.status(snapshot) + self._reconcile_blocked(snapshot, normalized_status) + normalized_status = self.status(snapshot) + self._reconcile_approvals(snapshot, normalized_status) + normalized_status = self.status(snapshot) + self._reconcile_blocked(snapshot, normalized_status) + normalized_status = self.status(snapshot) + node_map = {str(node["node_id"]): node for node in snapshot["nodes"]} + node_ids = plan_launches(snapshot, normalized_status, max_parallelism=self.max_parallelism) + plans = [self._bind(snapshot, node_map[node_id], retry=False) for node_id in node_ids] + results: dict[str, Any] = {} + with ThreadPoolExecutor(max_workers=self.max_parallelism) as pool: + futures: dict[str, Future[Any]] = { + plan.node_id: pool.submit(self._execute, snapshot, node_map[plan.node_id], plan) + for plan in plans + } + for node_id, future in futures.items(): + results[node_id] = future.result() + return {"plans": [plan.__dict__ for plan in plans], "results": results} + + def retry(self, snapshot: Mapping[str, Any], node_id: str) -> dict[str, Any]: + node_id = validate_id(node_id, "workflow node ID") + node = next((item for item in snapshot["nodes"] if item["node_id"] == node_id), None) + if node is None: + raise WorkflowError(f"unknown workflow node: {node_id}") + if node.get("kind", "task") == "approval": + raise WorkflowError("approval nodes are evidence gates and cannot be retried") + with workflow_lock(self.run_dir): + plan = self._bind(snapshot, node, retry=True) + return {"plan": plan.__dict__, "result": self._execute(snapshot, node, plan)} + + schedule = launch_eligible + + +Scheduler = SchedulerService diff --git a/src/agent_workflow/sessions.py b/src/agent_workflow/sessions.py index 11705f7..af29b8c 100644 --- a/src/agent_workflow/sessions.py +++ b/src/agent_workflow/sessions.py @@ -1,6 +1,7 @@ from __future__ import annotations import fcntl +import hashlib import json import os import platform @@ -38,6 +39,7 @@ write_status, ) from .util import ( + atomic_write_bytes, atomic_write_json, expand_path, sha256_file, @@ -45,6 +47,7 @@ validate_id, ) + def _ignore_delegations(workdir: Path) -> None: _add_git_exclude(workdir, ".delegations/") @@ -209,8 +212,8 @@ def _write_job_binding(state_dir: Path, job: ValidatedNativeJob, *, session_id: stored = state_dir / "jobs" / "native-job.json" stored.parent.mkdir(parents=True, exist_ok=True) raw = job.job_path.read_bytes() - stored.write_bytes(raw) - source_sha256 = sha256_file(job.job_path) + source_sha256 = hashlib.sha256(raw).hexdigest() + atomic_write_bytes(stored, raw, mode=0o444) stored_sha256 = sha256_file(stored) if source_sha256 != stored_sha256: raise WorkflowError("native job changed while its binding was being created") @@ -250,9 +253,7 @@ def _write_job_binding(state_dir: Path, job: ValidatedNativeJob, *, session_id: }, } receipt_path = state_dir / "job-binding.json" - atomic_write_json(receipt_path, receipt) - stored.chmod(0o444) - receipt_path.chmod(0o444) + atomic_write_json(receipt_path, receipt, mode=0o444) return receipt @@ -488,6 +489,7 @@ def launch( tier: str | None = None, job_path: Path | None = None, allow_active_agent_name: bool = False, + workflow_context: dict[str, Any] | None = None, ) -> dict[str, Any]: validate_id(session_id, "session ID") if ticket_id: @@ -732,6 +734,7 @@ def launch( "platform": platform.platform(), "implementation": sys.implementation.name, }, + retry_of_run_id=retry_of, job_binding=( { "path": str(state_dir / "job-binding.json"), @@ -743,6 +746,26 @@ def launch( ), ), ) + if workflow_context is not None: + from .contracts import validate_instance + validate_instance( + workflow_context, + "agent-workflow/workflow-input-bindings/v1", + artifact="workflow launch inputs", + ) + workflow_inputs_path = state_dir / "workflow-inputs.json" + atomic_write_json(workflow_inputs_path, workflow_context, mode=0o444) + update_provenance( + state_dir, + workflow={ + "workflow_id": workflow_context["workflow_id"], + "node_id": workflow_context["node_id"], + "attempt": workflow_context["attempt"], + "inputs_path": str(workflow_inputs_path), + "inputs_sha256": sha256_file(workflow_inputs_path), + "routing": None, + }, + ) if evaluation_path is not None or native_job is not None: evaluation = None evaluation_data: dict[str, Any] = {} diff --git a/src/agent_workflow/util.py b/src/agent_workflow/util.py index 1998032..c8e6990 100644 --- a/src/agent_workflow/util.py +++ b/src/agent_workflow/util.py @@ -45,16 +45,30 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def atomic_write_json(path: Path, data: dict[str, Any]) -> None: +def fsync_directory(path: Path) -> None: + """Persist directory-entry changes on POSIX filesystems.""" + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + descriptor = os.open(path, flags) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def atomic_write_bytes( + path: Path, data: bytes, *, mode: int | None = None +) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: - with os.fdopen(fd, "w", encoding="utf-8") as stream: - json.dump(data, stream, indent=2, sort_keys=True) - stream.write("\n") + with os.fdopen(fd, "wb") as stream: + stream.write(data) stream.flush() + if mode is not None: + os.fchmod(stream.fileno(), mode) os.fsync(stream.fileno()) os.replace(tmp, path) + fsync_directory(path.parent) finally: try: os.unlink(tmp) @@ -62,6 +76,13 @@ def atomic_write_json(path: Path, data: dict[str, Any]) -> None: pass +def atomic_write_json( + path: Path, data: dict[str, Any], *, mode: int | None = None +) -> None: + encoded = (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8") + atomic_write_bytes(path, encoded, mode=mode) + + def read_json(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) diff --git a/src/agent_workflow/workflow.py b/src/agent_workflow/workflow.py new file mode 100644 index 0000000..ecd05c9 --- /dev/null +++ b/src/agent_workflow/workflow.py @@ -0,0 +1,775 @@ +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import stat +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator, Mapping + +from .contracts import read_contract, validate_instance +from .errors import WorkflowError +from .util import atomic_write_json, fsync_directory, utc_now, validate_id + +WORKFLOW_SNAPSHOT_SCHEMA = "agent-workflow/workflow-snapshot/v1" +WORKFLOW_NODE_BINDING_SCHEMA = "agent-workflow/workflow-node-binding/v1" +WORKFLOW_NODE_RESULT_SCHEMA = "agent-workflow/workflow-node-result/v1" +WORKFLOW_EVENT_SCHEMA = "agent-workflow/workflow-event/v1" +WORKFLOW_STATUS_SCHEMA = "agent-workflow/workflow-status/v1" +WORKFLOW_RUN_SCHEMA = "agent-workflow/workflow-run/v1" + +NODE_STATES = {"blocked", "eligible", "running", "completed", "failed", "recoverable"} +TERMINAL_NODE_STATES = {"completed", "failed"} +ALLOWED_TRANSITIONS: dict[str, set[str]] = { + "blocked": {"eligible", "failed"}, + "eligible": {"running", "completed", "failed", "recoverable"}, + "running": {"completed", "failed", "recoverable"}, + "completed": set(), + "failed": {"blocked", "eligible"}, + "recoverable": {"eligible"}, +} + + +def workflow_snapshot_path(run_dir: Path) -> Path: + return run_dir / "workflow-snapshot.json" + + +def workflow_events_path(run_dir: Path) -> Path: + return run_dir / "workflow-events.jsonl" + + +def workflow_status_path(run_dir: Path) -> Path: + return run_dir / "workflow-status.json" + + +def workflow_run_path(run_dir: Path) -> Path: + return run_dir / "workflow-run.json" + + +def read_stored_workflow_snapshot(path: Path) -> dict[str, Any]: + """Read the canonical snapshot through a stable non-symlink descriptor.""" + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open workflow snapshot {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"workflow snapshot must be a regular file: {path}") + if info.st_mode & 0o222: + raise WorkflowError(f"workflow snapshot must be read-only: {path}") + data = stream.read() + try: + value = json.loads(data.decode("utf-8")) + except UnicodeDecodeError as exc: + raise WorkflowError(f"workflow snapshot is not UTF-8: {path}") from exc + except json.JSONDecodeError as exc: + raise WorkflowError(f"invalid JSON in workflow snapshot {path}: {exc}") from exc + if not isinstance(value, dict): + raise WorkflowError(f"workflow snapshot must be a JSON object: {path}") + if value.get("schema") != WORKFLOW_SNAPSHOT_SCHEMA: + raise WorkflowError( + f"unexpected workflow snapshot schema in {path}: {value.get('schema')!r}" + ) + validate_instance(value, WORKFLOW_SNAPSHOT_SCHEMA, artifact=str(path)) + return normalize_snapshot(value) + + +@contextmanager +def workflow_lock(run_dir: Path, *, exclusive: bool = True) -> Iterator[None]: + """Serialize workflow mutations and stable evidence reads.""" + run_dir.mkdir(parents=True, exist_ok=True) + path = run_dir / "workflow.lock" + existed = path.exists() or path.is_symlink() + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags, 0o600) + except OSError as exc: + raise WorkflowError(f"cannot open workflow lock {path}: {exc}") from exc + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"workflow lock must be a regular file: {path}") + if not existed: + os.fsync(descriptor) + fsync_directory(path.parent) + operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + fcntl.flock(descriptor, operation) + try: + yield + finally: + fcntl.flock(descriptor, fcntl.LOCK_UN) + finally: + os.close(descriptor) + + +def ensure_workflow_events_file(run_dir: Path) -> Path: + """Create or validate the canonical workflow event journal safely.""" + run_dir.mkdir(parents=True, exist_ok=True) + path = workflow_events_path(run_dir) + existed = path.exists() or path.is_symlink() + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags, 0o600) + except OSError as exc: + raise WorkflowError(f"cannot open workflow event journal {path}: {exc}") from exc + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"workflow event journal must be a regular file: {path}") + os.fsync(descriptor) + finally: + os.close(descriptor) + if not existed: + fsync_directory(path.parent) + return path + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _validate_input_bindings(value: Any, *, location: str) -> dict[str, dict[str, Any]]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise WorkflowError(f"{location}: input_bindings must be a mapping") + if len(value) > 64: + raise WorkflowError(f"{location}: at most 64 input bindings are allowed") + result: dict[str, dict[str, Any]] = {} + for name, raw in sorted(value.items(), key=lambda item: str(item[0])): + binding_name = validate_id(str(name), "workflow input binding name") + if not isinstance(raw, Mapping): + raise WorkflowError(f"{location}.{binding_name}: expected mapping") + source_node_id = validate_id( + str(raw.get("source_node_id", "")), "workflow source node ID" + ) + pointer = raw.get("pointer", "") + if not isinstance(pointer, str): + raise WorkflowError(f"{location}.{binding_name}: pointer must be a string") + required = raw.get("required", True) + if not isinstance(required, bool): + raise WorkflowError(f"{location}.{binding_name}: required must be boolean") + max_bytes = raw.get("max_bytes", 65536) + if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or not 1 <= max_bytes <= 1048576: + raise WorkflowError( + f"{location}.{binding_name}: max_bytes must be between 1 and 1048576" + ) + result[binding_name] = { + "source_node_id": source_node_id, + "pointer": pointer, + "required": required, + "max_bytes": max_bytes, + } + return result + + +def _validate_node(node: Mapping[str, Any], *, location: str) -> dict[str, Any]: + if not isinstance(node, Mapping): + raise WorkflowError(f"{location}: expected mapping") + node_id = validate_id(str(node.get("node_id", "")), "workflow node ID") + kind = str(node.get("kind", "task")) + if kind not in {"task", "approval"}: + raise WorkflowError(f"{location}: unsupported workflow node kind: {kind}") + dependencies = node.get("dependencies", []) + if dependencies is None: + dependencies = [] + if not isinstance(dependencies, list) or any( + not isinstance(item, str) or not item for item in dependencies + ): + raise WorkflowError(f"{location}: dependencies must be a list of node IDs") + normalized_dependencies = [ + validate_id(str(item), "workflow dependency node ID") for item in dependencies + ] + if len(normalized_dependencies) != len(set(normalized_dependencies)): + raise WorkflowError(f"{location}: duplicate dependency IDs are not allowed") + result: dict[str, Any] = { + "node_id": node_id, + "kind": kind, + "dependencies": normalized_dependencies, + } + if kind == "task": + session_id = validate_id(str(node.get("session_id", "")), "workflow session ID") + prompt_path = str(node.get("prompt_path", "")) + if not prompt_path: + raise WorkflowError(f"{location}: prompt_path is required") + result.update(session_id=session_id, prompt_path=prompt_path) + for name in ("ticket_id", "tier", "pack_id", "agent_class", "executor", "model"): + value = node.get(name) + if value is not None: + result[name] = str(value) + for name in ("interactive", "allow_no_go_model"): + value = node.get(name) + if value is not None: + if not isinstance(value, bool): + raise WorkflowError(f"{location}: {name} must be boolean") + result[name] = value + routing = node.get("routing") + if routing is not None: + if not isinstance(routing, Mapping): + raise WorkflowError(f"{location}: routing must be a mapping") + encoded = _canonical_json(dict(routing)) + if len(encoded) > 16384: + raise WorkflowError(f"{location}: routing metadata exceeds 16384 bytes") + result["routing"] = dict(routing) + bindings = _validate_input_bindings( + node.get("input_bindings"), location=f"{location}.input_bindings" + ) + if bindings: + result["input_bindings"] = bindings + else: + approval_for = validate_id( + str(node.get("approval_for", "")), "approval subject node ID" + ) + result["approval_for"] = approval_for + if approval_for not in result["dependencies"]: + raise WorkflowError( + f"{location}: approval node must depend on approval_for node {approval_for}" + ) + return result + + +def normalize_snapshot(snapshot: Mapping[str, Any]) -> dict[str, Any]: + if not isinstance(snapshot, Mapping): + raise WorkflowError("workflow snapshot must be a mapping") + workflow_id = validate_id(str(snapshot.get("workflow_id", "")), "workflow ID") + pack_id = str(snapshot.get("pack_id", "")) + if not pack_id: + raise WorkflowError("workflow snapshot pack_id is required") + pack_manifest_sha256 = str(snapshot.get("pack_manifest_sha256", "")) + if not pack_manifest_sha256: + raise WorkflowError("workflow snapshot pack_manifest_sha256 is required") + nodes = snapshot.get("nodes") + if not isinstance(nodes, list) or not nodes: + raise WorkflowError("workflow snapshot nodes must be a non-empty list") + normalized_nodes: list[dict[str, Any]] = [] + seen: set[str] = set() + seen_session_ids: set[str] = set() + for index, node in enumerate(nodes): + normalized = _validate_node(node, location=f"nodes[{index}]") + node_id = normalized["node_id"] + if node_id in seen: + raise WorkflowError(f"duplicate workflow node ID: {node_id}") + seen.add(node_id) + if normalized["kind"] == "task": + session_id = str(normalized["session_id"]) + if session_id in seen_session_ids: + raise WorkflowError(f"duplicate workflow session ID: {session_id}") + seen_session_ids.add(session_id) + normalized_nodes.append(normalized) + graph = {item["node_id"]: list(item["dependencies"]) for item in normalized_nodes} + unknown = sorted( + dependency + for dependencies in graph.values() + for dependency in dependencies + if dependency not in graph + ) + if unknown: + raise WorkflowError( + "workflow snapshot references unknown dependency IDs: " + + ", ".join(sorted(set(unknown))) + ) + visiting: set[str] = set() + visited: set[str] = set() + stack: list[str] = [] + + def visit(node_id: str) -> list[str] | None: + if node_id in visiting: + start = stack.index(node_id) + return stack[start:] + [node_id] + if node_id in visited: + return None + visiting.add(node_id) + stack.append(node_id) + for dependency in graph[node_id]: + cycle = visit(dependency) + if cycle: + return cycle + stack.pop() + visiting.remove(node_id) + visited.add(node_id) + return None + + for node_id in sorted(graph): + cycle = visit(node_id) + if cycle: + raise WorkflowError( + "workflow snapshot contains a dependency cycle: " + + " -> ".join(cycle) + ) + normalized_nodes.sort(key=lambda item: item["node_id"]) + for item in normalized_nodes: + item["dependencies"] = sorted(item["dependencies"]) + normalized = { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": workflow_id, + "pack_id": pack_id, + "pack_manifest_sha256": pack_manifest_sha256, + "nodes": normalized_nodes, + } + validate_instance(normalized, WORKFLOW_SNAPSHOT_SCHEMA, artifact="workflow snapshot") + return normalized + + +def snapshot_sha256(snapshot: Mapping[str, Any]) -> str: + normalized = normalize_snapshot(snapshot) + return hashlib.sha256(_canonical_json(normalized)).hexdigest() + + +def initial_status(snapshot: Mapping[str, Any]) -> dict[str, Any]: + normalized = normalize_snapshot(snapshot) + nodes = [ + { + "node_id": node["node_id"], + "kind": node["kind"], + "state": "eligible" if not node["dependencies"] else "blocked", + "run_id": None, + "attempt": None, + "retry_of_run_id": None, + "bound_at": None, + "terminal_reason": None, + "approval_receipt_sha256": None, + "input_binding_sha256": None, + } + for node in normalized["nodes"] + ] + status = { + "schema": WORKFLOW_STATUS_SCHEMA, + "workflow_id": normalized["workflow_id"], + "snapshot_sha256": snapshot_sha256(normalized), + "event_count": 0, + "workflow_state": _aggregate_workflow_state(nodes), + "nodes": nodes, + } + validate_instance(status, WORKFLOW_STATUS_SCHEMA, artifact="workflow status") + return status + + +def _aggregate_workflow_state(nodes: list[dict[str, Any]]) -> str: + states = [str(item["state"]) for item in nodes] + if any(state == "failed" for state in states): + return "failed" + if any(state == "running" for state in states): + return "running" + if any(state == "recoverable" for state in states): + return "recoverable" + if all(state == "completed" for state in states): + return "completed" + if any(state == "eligible" for state in states): + return "eligible" + return "blocked" + + +def _binding_record(event: Mapping[str, Any]) -> dict[str, Any]: + binding = event.get("binding") + if not isinstance(binding, Mapping): + raise WorkflowError("workflow binding event missing binding record") + required = ["run_id", "attempt", "bound_at", "current"] + missing = [name for name in required if name not in binding] + if missing: + raise WorkflowError( + "workflow binding event missing fields: " + ", ".join(missing) + ) + node_id = validate_id(str(event.get("node_id", "")), "workflow node ID") + run_id = validate_id(str(binding.get("run_id", "")), "workflow run ID") + retry_of_run_id = binding.get("retry_of_run_id") + if retry_of_run_id is not None: + retry_of_run_id = validate_id(str(retry_of_run_id), "retry run ID") + attempt = binding.get("attempt") + if not isinstance(attempt, int) or attempt < 1: + raise WorkflowError("workflow binding attempt must be a positive integer") + bound_at = str(binding.get("bound_at")) + if not bound_at: + raise WorkflowError("workflow binding bound_at is required") + current = binding.get("current") + if not isinstance(current, bool): + raise WorkflowError("workflow binding current must be a boolean") + result = { + "schema": WORKFLOW_NODE_BINDING_SCHEMA, + "workflow_id": str(event.get("workflow_id", "")), + "node_id": node_id, + "run_id": run_id, + "attempt": attempt, + "retry_of_run_id": retry_of_run_id, + "bound_at": bound_at, + "current": current, + } + validate_instance(result, WORKFLOW_NODE_BINDING_SCHEMA, artifact="workflow binding") + return result + + +def _transition_record(event: Mapping[str, Any]) -> tuple[str, str, str]: + previous_state = str(event.get("previous_state", "")) + next_state = str(event.get("next_state", "")) + reason = str(event.get("reason", "")) + if previous_state not in NODE_STATES: + raise WorkflowError("workflow transition has invalid previous_state") + if next_state not in NODE_STATES: + raise WorkflowError("workflow transition has invalid next_state") + if not reason: + raise WorkflowError("workflow transition reason is required") + if next_state not in ALLOWED_TRANSITIONS[previous_state]: + raise WorkflowError( + f"invalid workflow transition: {previous_state} -> {next_state}" + ) + return previous_state, next_state, reason + + +def append_workflow_event(run_dir: Path, event: Mapping[str, Any]) -> dict[str, Any]: + path = ensure_workflow_events_file(run_dir) + event_data = dict(event) + event_data["schema"] = WORKFLOW_EVENT_SCHEMA + flags = os.O_RDWR | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open workflow event journal {path}: {exc}") from exc + with os.fdopen(descriptor, "a+b") as stream: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX) + stream.seek(0) + try: + existing = stream.read().decode("utf-8") + except UnicodeDecodeError as exc: + raise WorkflowError(f"workflow event journal is not UTF-8: {path}") from exc + sequence = len(_parse_event_lines(existing.splitlines(), path)) + 1 + event_data["sequence"] = sequence + event_data.setdefault("timestamp", utc_now()) + validate_instance(event_data, WORKFLOW_EVENT_SCHEMA, artifact=str(path)) + stream.seek(0, os.SEEK_END) + stream.write( + json.dumps(event_data, sort_keys=True, separators=(",", ":")).encode() + + b"\n" + ) + stream.flush() + os.fsync(stream.fileno()) + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + return event_data + + +def record_workflow_binding( + run_dir: Path, + *, + workflow_id: str, + node_id: str, + run_id: str, + attempt: int, + actor: str, + reason: str, + snapshot_sha256: str, + retry_of_run_id: str | None = None, + current: bool = True, +) -> dict[str, Any]: + event = append_workflow_event( + run_dir, + { + "workflow_id": validate_id(workflow_id, "workflow ID"), + "snapshot_sha256": snapshot_sha256, + "kind": "node-bound", + "node_id": validate_id(node_id, "workflow node ID"), + "actor": actor, + "reason": reason, + "binding": { + "run_id": validate_id(run_id, "workflow run ID"), + "attempt": attempt, + "retry_of_run_id": ( + validate_id(retry_of_run_id, "retry run ID") + if retry_of_run_id is not None + else None + ), + "bound_at": utc_now(), + "current": current, + }, + }, + ) + return event + + +def record_workflow_transition( + run_dir: Path, + *, + workflow_id: str, + node_id: str, + actor: str, + reason: str, + snapshot_sha256: str, + previous_state: str, + next_state: str, + details: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + event = append_workflow_event( + run_dir, + { + "workflow_id": validate_id(workflow_id, "workflow ID"), + "snapshot_sha256": snapshot_sha256, + "kind": "node-transition", + "node_id": validate_id(node_id, "workflow node ID"), + "actor": actor, + "reason": reason, + "previous_state": previous_state, + "next_state": next_state, + "details": dict(details) if details is not None else None, + }, + ) + return event + + +def _parse_event_lines(lines: list[str], path: Path) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + expected = 1 + for line_number, line in enumerate(lines, start=1): + if not line.strip(): + raise WorkflowError(f"blank workflow event at line {line_number}") + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise WorkflowError( + f"invalid workflow event JSON at line {line_number}: {exc}" + ) from exc + if not isinstance(event, dict): + raise WorkflowError(f"workflow event at line {line_number} must be an object") + validate_instance(event, WORKFLOW_EVENT_SCHEMA, artifact=str(path)) + if event.get("sequence") != expected: + raise WorkflowError( + f"workflow event sequence mismatch at line {line_number}: expected {expected}" + ) + events.append(event) + expected += 1 + return events + + +def read_workflow_events(path: Path) -> list[dict[str, Any]]: + """Read a stable journal snapshot from a regular non-symlink file.""" + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open workflow events {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError( + f"workflow event journal must be a regular non-symlink file: {path}" + ) + fcntl.flock(stream.fileno(), fcntl.LOCK_SH) + data = stream.read() + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + try: + lines = data.decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise WorkflowError(f"workflow event journal is not UTF-8: {path}") from exc + return _parse_event_lines(lines, path) + + +def _read_events(path: Path) -> list[dict[str, Any]]: + return read_workflow_events(path) + + +def _transition_summary( + *, + nodes: dict[str, dict[str, Any]], + dependencies: dict[str, list[str]], + kinds: dict[str, str], + node_id: str, + previous_state: str, + next_state: str, + reason: str, +) -> None: + if node_id not in nodes: + raise WorkflowError(f"workflow event references unknown node: {node_id}") + current = nodes[node_id]["state"] + if current != previous_state: + raise WorkflowError( + f"workflow transition mismatch for {node_id}: expected {current}, got {previous_state}" + ) + if next_state not in ALLOWED_TRANSITIONS[previous_state]: + raise WorkflowError( + f"invalid workflow transition for {node_id}: {previous_state} -> {next_state}" + ) + if ( + kinds[node_id] == "task" + and ( + next_state in {"running", "completed"} + or (next_state == "failed" and previous_state != "blocked") + ) + and nodes[node_id]["run_id"] is None + ): + raise WorkflowError( + f"workflow node {node_id} cannot enter {next_state} without a current binding" + ) + if previous_state == "blocked" and next_state == "eligible": + unresolved = [dep for dep in dependencies[node_id] if nodes[dep]["state"] != "completed"] + if unresolved: + raise WorkflowError( + "workflow node " + f"{node_id} cannot become eligible before dependencies complete: " + + ", ".join(unresolved) + ) + if previous_state in {"failed", "recoverable"} and next_state == "eligible": + attempt = nodes[node_id]["attempt"] + if not isinstance(attempt, int) or attempt < 2: + raise WorkflowError( + f"workflow node {node_id} cannot retry before a newer current binding" + ) + nodes[node_id]["state"] = next_state + if next_state in TERMINAL_NODE_STATES: + nodes[node_id]["terminal_reason"] = reason + else: + nodes[node_id]["terminal_reason"] = None + + +def reconstruct_workflow_status( + snapshot: Mapping[str, Any], + events_path: Path, +) -> dict[str, Any]: + normalized = normalize_snapshot(snapshot) + dependencies = {node["node_id"]: list(node["dependencies"]) for node in normalized["nodes"]} + kinds = {node["node_id"]: str(node["kind"]) for node in normalized["nodes"]} + nodes = { + node["node_id"]: { + "node_id": node["node_id"], + "kind": node["kind"], + "state": "eligible" if not node["dependencies"] else "blocked", + "run_id": None, + "attempt": None, + "retry_of_run_id": None, + "bound_at": None, + "terminal_reason": None, + } + for node in normalized["nodes"] + } + event_count = 0 + seen_run_ids: set[str] = set() + for event in _read_events(events_path): + event_count += 1 + if event["workflow_id"] != normalized["workflow_id"]: + raise WorkflowError("workflow event references a different workflow") + if event["snapshot_sha256"] != snapshot_sha256(normalized): + raise WorkflowError("workflow event snapshot digest mismatch") + kind = event["kind"] + node_id = str(event["node_id"]) + if kind == "node-bound": + if node_id not in nodes: + raise WorkflowError(f"workflow event references unknown node: {node_id}") + binding = _binding_record(event) + if binding["node_id"] != node_id: + raise WorkflowError("workflow binding node mismatch") + if not binding["current"]: + raise WorkflowError("workflow binding record must be current") + current_state = nodes[node_id]["state"] + if current_state in {"blocked", "running", "completed"}: + raise WorkflowError( + f"workflow node {node_id} cannot be bound while {current_state}" + ) + prior_run_id = nodes[node_id]["run_id"] + prior_attempt = nodes[node_id]["attempt"] + if prior_run_id is None: + if current_state != "eligible": + raise WorkflowError( + f"workflow node {node_id} must be eligible before first binding" + ) + if binding["attempt"] != 1: + raise WorkflowError( + f"workflow node {node_id} first binding must use attempt 1" + ) + if binding["retry_of_run_id"] is not None: + raise WorkflowError( + f"workflow node {node_id} first binding cannot have retry lineage" + ) + else: + if current_state not in {"failed", "recoverable"}: + raise WorkflowError( + f"workflow node {node_id} retry requires failed or recoverable state" + ) + if not isinstance(prior_attempt, int) or binding["attempt"] != prior_attempt + 1: + raise WorkflowError( + f"workflow node {node_id} retry attempt must be the next attempt" + ) + if binding["retry_of_run_id"] != prior_run_id: + raise WorkflowError( + f"workflow node {node_id} retry must reference the current run" + ) + if binding["run_id"] in seen_run_ids: + raise WorkflowError(f"workflow run ID reused: {binding['run_id']}") + seen_run_ids.add(binding["run_id"]) + nodes[node_id].update( + run_id=binding["run_id"], + attempt=binding["attempt"], + retry_of_run_id=binding["retry_of_run_id"], + bound_at=binding["bound_at"], + ) + elif kind == "node-transition": + previous_state, next_state, reason = _transition_record(event) + _transition_summary( + nodes=nodes, + dependencies=dependencies, + kinds=kinds, + node_id=node_id, + previous_state=previous_state, + next_state=next_state, + reason=reason, + ) + details = event.get("details") + if isinstance(details, Mapping): + approval_digest = details.get("approval_receipt_sha256") + binding_digest = details.get("input_binding_sha256") + if isinstance(approval_digest, str): + nodes[node_id]["approval_receipt_sha256"] = approval_digest + if isinstance(binding_digest, str): + nodes[node_id]["input_binding_sha256"] = binding_digest + else: + raise WorkflowError(f"unknown workflow event kind: {kind}") + node_list = [nodes[node_id] for node_id in sorted(nodes)] + status = { + "schema": WORKFLOW_STATUS_SCHEMA, + "workflow_id": normalized["workflow_id"], + "snapshot_sha256": snapshot_sha256(normalized), + "event_count": event_count, + "workflow_state": _aggregate_workflow_state(node_list), + "nodes": node_list, + } + validate_instance(status, WORKFLOW_STATUS_SCHEMA, artifact="workflow status") + return status + + +def build_workflow_run( + *, + snapshot: Mapping[str, Any], + snapshot_path: Path, + events_path: Path, + status_path: Path, +) -> dict[str, Any]: + normalized = normalize_snapshot(snapshot) + status = reconstruct_workflow_status(normalized, events_path) + run = { + "schema": WORKFLOW_RUN_SCHEMA, + "workflow_id": normalized["workflow_id"], + "snapshot_sha256": snapshot_sha256(normalized), + "snapshot_path": str(snapshot_path), + "events_path": str(events_path), + "status_path": str(status_path), + "status": status, + } + validate_instance(run, WORKFLOW_RUN_SCHEMA, artifact="workflow run") + return run + + +def write_workflow_projection( + *, + snapshot: Mapping[str, Any], + snapshot_path: Path, + events_path: Path, + status_path: Path, + run_path: Path, +) -> dict[str, Any]: + run = build_workflow_run( + snapshot=snapshot, + snapshot_path=snapshot_path, + events_path=events_path, + status_path=status_path, + ) + atomic_write_json(status_path, run["status"]) + atomic_write_json(run_path, run) + return run diff --git a/src/agent_workflow/workflow_receipt.py b/src/agent_workflow/workflow_receipt.py new file mode 100644 index 0000000..9430a25 --- /dev/null +++ b/src/agent_workflow/workflow_receipt.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import hashlib +import json +import os +import stat +from pathlib import Path +from typing import Any, Mapping + +from .contracts import read_contract, validate_instance +from .approval import lifecycle_disposition +from .errors import WorkflowError +from .receipts import read_sealed_contract, verify_seal_details +from .state import run_dir as session_run_dir +from .util import atomic_write_json, sha256_file, utc_now +from .workflow import ( + TERMINAL_NODE_STATES, + normalize_snapshot, + read_stored_workflow_snapshot, + read_workflow_events, + reconstruct_workflow_status, + snapshot_sha256, + workflow_events_path, + workflow_lock, + workflow_snapshot_path, +) + +WORKFLOW_RECEIPT_SCHEMA = "agent-workflow/workflow-receipt/v1" + + +def workflow_receipt_path(run_dir: Path) -> Path: + return Path(run_dir) / "workflow-receipt.json" + + +def _read_workflow_receipt(path: Path) -> tuple[dict[str, Any], str]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + raise WorkflowError(f"cannot open workflow receipt {path}: {exc}") from exc + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode): + raise WorkflowError(f"workflow receipt must be a regular file: {path}") + if info.st_mode & 0o222: + raise WorkflowError("workflow receipt must be read-only") + data = stream.read() + try: + receipt = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WorkflowError(f"cannot read workflow receipt {path}: {exc}") from exc + if not isinstance(receipt, dict): + raise WorkflowError(f"workflow receipt must be a JSON object: {path}") + validate_instance(receipt, WORKFLOW_RECEIPT_SCHEMA, artifact=str(path)) + return receipt, hashlib.sha256(data).hexdigest() + + +def _binding_history(events_path: Path) -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = {} + for event in read_workflow_events(events_path): + if event.get("kind") != "node-bound": + continue + binding = event.get("binding") + if not isinstance(binding, Mapping): + raise WorkflowError("workflow node-bound event is missing binding") + node_id = str(event.get("node_id", "")) + result.setdefault(node_id, []).append( + { + "run_id": binding.get("run_id"), + "attempt": binding.get("attempt"), + "retry_of_run_id": binding.get("retry_of_run_id"), + "bound_at": binding.get("bound_at"), + } + ) + return result + + +def _task_receipt(settings: Any, node_status: Mapping[str, Any]) -> dict[str, Any]: + run_id = node_status.get("run_id") + if not isinstance(run_id, str) or not run_id: + if node_status.get("state") == "completed": + raise WorkflowError( + f"completed task {node_status.get('node_id')} has no child run binding" + ) + return { + "child_run_id": None, + "child_final_receipt_sha256": None, + "child_completion_sha256": None, + } + child = session_run_dir(settings, run_id).resolve() + receipt_path = child / "final-receipt.json" + if not receipt_path.is_file(): + if node_status.get("state") == "completed": + raise WorkflowError(f"completed task child run is not sealed: {run_id}") + return { + "child_run_id": run_id, + "child_final_receipt_sha256": None, + "child_completion_sha256": None, + } + final_receipt, expected = verify_seal_details(child) + if final_receipt.get("session_id") != run_id: + raise WorkflowError(f"child final receipt belongs to another run: {run_id}") + _, completion_digest = read_sealed_contract( + child, + final_receipt, + "completion.json", + "agent-workflow/completion/v1", + ) + return { + "child_run_id": run_id, + "child_final_receipt_sha256": expected, + "child_completion_sha256": completion_digest, + } + + +def _approval_receipt_digest( + *, + settings: Any, + node: Mapping[str, Any], + node_status: Mapping[str, Any], + status_by_id: Mapping[str, Mapping[str, Any]], +) -> str | None: + recorded = node_status.get("approval_receipt_sha256") + if recorded is None: + if node_status.get("state") == "completed": + raise WorkflowError( + f"completed approval node {node_status.get('node_id')} has no approval receipt digest" + ) + return None + if not isinstance(recorded, str) or not recorded: + raise WorkflowError( + f"approval node {node_status.get('node_id')} has an invalid receipt digest" + ) + subject_id = str(node.get("approval_for", "")) + subject_status = status_by_id.get(subject_id) + run_id = subject_status.get("run_id") if isinstance(subject_status, Mapping) else None + if not isinstance(run_id, str) or not run_id: + raise WorkflowError( + f"approval node {node_status.get('node_id')} has no authoritative subject run" + ) + disposition = lifecycle_disposition(session_run_dir(settings, run_id)) + if disposition is None: + raise WorkflowError( + f"approval node {node_status.get('node_id')} has no canonical lifecycle disposition" + ) + if disposition["receipt_sha256"] != recorded: + raise WorkflowError( + f"approval node {node_status.get('node_id')} receipt digest does not match canonical evidence" + ) + expected_action = "accepted" if node_status.get("state") == "completed" else "rejected" + if disposition["action"] != expected_action: + raise WorkflowError( + f"approval node {node_status.get('node_id')} terminal state contradicts canonical disposition" + ) + return recorded + + +def _build_workflow_receipt_unlocked( + *, settings: Any, run_dir: Path, created_at: str | None = None +) -> dict[str, Any]: + run_dir = Path(run_dir).resolve() + snapshot_path = workflow_snapshot_path(run_dir) + events_path = workflow_events_path(run_dir) + snapshot = read_stored_workflow_snapshot(snapshot_path) + if not events_path.is_file(): + raise WorkflowError("workflow event journal is missing") + status = reconstruct_workflow_status(snapshot, events_path) + nonterminal = [ + item["node_id"] + for item in status["nodes"] + if item["state"] not in TERMINAL_NODE_STATES + ] + if nonterminal: + raise WorkflowError( + "workflow receipt requires terminal nodes; nonterminal: " + + ", ".join(sorted(nonterminal)) + ) + histories = _binding_history(events_path) + snapshot_nodes = {str(item["node_id"]): item for item in snapshot["nodes"]} + status_by_id = {str(item["node_id"]): item for item in status["nodes"]} + receipt_nodes: list[dict[str, Any]] = [] + for node_status in status["nodes"]: + node_id = str(node_status["node_id"]) + node = snapshot_nodes[node_id] + kind = str(node.get("kind", "task")) + task_evidence = ( + _task_receipt(settings, node_status) + if kind == "task" + else { + "child_run_id": None, + "child_final_receipt_sha256": None, + "child_completion_sha256": None, + } + ) + approval_digest = ( + _approval_receipt_digest( + settings=settings, + node=node, + node_status=node_status, + status_by_id=status_by_id, + ) + if kind == "approval" + else None + ) + receipt_nodes.append( + { + "node_id": node_id, + "kind": kind, + "state": node_status["state"], + "terminal_reason": node_status.get("terminal_reason"), + "attempt": node_status.get("attempt"), + "retry_of_run_id": node_status.get("retry_of_run_id"), + "binding_history": histories.get(node_id, []), + "input_binding_sha256": node_status.get("input_binding_sha256"), + "approval_receipt_sha256": approval_digest, + **task_evidence, + } + ) + receipt = { + "schema": WORKFLOW_RECEIPT_SCHEMA, + "workflow_id": snapshot["workflow_id"], + "created_at": created_at or utc_now(), + "workflow_state": status["workflow_state"], + "snapshot_sha256": snapshot_sha256(snapshot), + "snapshot_file_sha256": sha256_file(snapshot_path), + "events_sha256": sha256_file(events_path), + "event_count": status["event_count"], + "node_count": len(receipt_nodes), + "nodes": receipt_nodes, + } + validate_instance(receipt, WORKFLOW_RECEIPT_SCHEMA, artifact="workflow receipt") + return receipt + + +def build_workflow_receipt( + *, settings: Any, run_dir: Path, created_at: str | None = None +) -> dict[str, Any]: + run_dir = Path(run_dir).resolve() + with workflow_lock(run_dir, exclusive=False): + return _build_workflow_receipt_unlocked( + settings=settings, run_dir=run_dir, created_at=created_at + ) + + +def _verify_workflow_receipt_unlocked(*, settings: Any, run_dir: Path) -> dict[str, Any]: + path = workflow_receipt_path(run_dir) + receipt, receipt_digest = _read_workflow_receipt(path) + rebuilt = _build_workflow_receipt_unlocked( + settings=settings, run_dir=run_dir, created_at=str(receipt["created_at"]) + ) + if receipt != rebuilt: + raise WorkflowError("workflow receipt does not match current durable evidence") + return { + "receipt": receipt, + "receipt_path": str(path), + "receipt_sha256": receipt_digest, + "verified": True, + } + + +def seal_workflow(*, settings: Any, run_dir: Path) -> dict[str, Any]: + run_dir = Path(run_dir).resolve() + with workflow_lock(run_dir): + path = workflow_receipt_path(run_dir) + if path.exists() or path.is_symlink(): + return _verify_workflow_receipt_unlocked( + settings=settings, run_dir=run_dir + ) + receipt = _build_workflow_receipt_unlocked(settings=settings, run_dir=run_dir) + atomic_write_json(path, receipt, mode=0o444) + stored, receipt_digest = _read_workflow_receipt(path) + if stored != receipt: + raise WorkflowError("workflow receipt changed during atomic installation") + return { + "receipt": receipt, + "receipt_path": str(path), + "receipt_sha256": receipt_digest, + "verified": True, + } + + +def verify_workflow_receipt(*, settings: Any, run_dir: Path) -> dict[str, Any]: + run_dir = Path(run_dir).resolve() + with workflow_lock(run_dir, exclusive=False): + return _verify_workflow_receipt_unlocked(settings=settings, run_dir=run_dir) diff --git a/src/agent_workflow/workflow_service.py b/src/agent_workflow/workflow_service.py new file mode 100644 index 0000000..19f4a88 --- /dev/null +++ b/src/agent_workflow/workflow_service.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +from .contracts import read_contract, validate_instance +from .errors import WorkflowError +from .scheduler import SchedulerService +from .util import atomic_write_json, expand_path +from .workflow_receipt import seal_workflow, verify_workflow_receipt +from .workflow import ( + WORKFLOW_NODE_RESULT_SCHEMA, + ensure_workflow_events_file, + normalize_snapshot, + read_stored_workflow_snapshot, + snapshot_sha256, + write_workflow_projection, + workflow_events_path, + workflow_lock, + workflow_run_path, + workflow_snapshot_path, + workflow_status_path, +) + + +def _result(action: str, workflow_id: str, result: Mapping[str, Any], **extra: Any) -> dict[str, Any]: + data = { + "schema": WORKFLOW_NODE_RESULT_SCHEMA, + "workflow_id": workflow_id, + "action": action, + "result": dict(result), + **extra, + } + validate_instance(data, WORKFLOW_NODE_RESULT_SCHEMA, artifact="workflow node result") + return data + + +def _workflow_io_error(action: str, exc: OSError) -> WorkflowError: + return WorkflowError(f"workflow {action} failed: {exc}") + + +def _matching_started_snapshot(run_dir: Path, supplied: Mapping[str, Any]) -> dict[str, Any]: + stored_path = workflow_snapshot_path(run_dir) + if not stored_path.exists() and not stored_path.is_symlink(): + raise WorkflowError("workflow has not been started") + stored = read_stored_workflow_snapshot(stored_path) + if snapshot_sha256(stored) != snapshot_sha256(supplied): + raise WorkflowError("supplied workflow snapshot does not match the started snapshot") + return stored + + +def _write_projection(run_dir: Path, snapshot: Mapping[str, Any]) -> dict[str, Any]: + return write_workflow_projection( + snapshot=snapshot, + snapshot_path=workflow_snapshot_path(run_dir), + events_path=workflow_events_path(run_dir), + status_path=workflow_status_path(run_dir), + run_path=workflow_run_path(run_dir), + ) + + +@dataclass(frozen=True) +class WorkflowService: + scheduler: SchedulerService + + def validate(self, snapshot_path: Path) -> dict[str, Any]: + snapshot_file = expand_path(snapshot_path) + snapshot = normalize_snapshot(read_contract(snapshot_file)) + return _result( + "validate", + str(snapshot["workflow_id"]), + {"snapshot_sha256": snapshot_sha256(snapshot), "node_count": len(snapshot["nodes"])}, + snapshot_path=str(snapshot_file), + ) + def start(self, snapshot_path: Path) -> dict[str, Any]: + snapshot_file = expand_path(snapshot_path) + snapshot = normalize_snapshot(read_contract(snapshot_file)) + run_dir = self.scheduler.run_dir + try: + with workflow_lock(run_dir): + stored_snapshot = workflow_snapshot_path(run_dir) + if stored_snapshot.exists() or stored_snapshot.is_symlink(): + raise WorkflowError(f"workflow already started: {snapshot['workflow_id']}") + if workflow_run_path(run_dir).exists() or workflow_run_path(run_dir).is_symlink(): + raise WorkflowError( + f"workflow run projection exists without canonical snapshot: {run_dir}" + ) + atomic_write_json(stored_snapshot, snapshot, mode=0o444) + ensure_workflow_events_file(run_dir) + _write_projection(run_dir, snapshot) + except OSError as exc: + raise _workflow_io_error("start", exc) from exc + schedule = self.scheduler.launch_eligible(snapshot) + try: + with workflow_lock(run_dir): + _write_projection(run_dir, snapshot) + except OSError as exc: + raise _workflow_io_error("start", exc) from exc + return _result( + "start", + str(snapshot["workflow_id"]), + {"run_count": len(schedule["plans"]), "plans": schedule["plans"], "results": schedule["results"]}, + snapshot_path=str(workflow_snapshot_path(run_dir)), + events_path=str(workflow_events_path(run_dir)), + status_path=str(workflow_status_path(run_dir)), + run_path=str(workflow_run_path(run_dir)), + started=True, + resumed=False, + scheduled=[str(item["node_id"]) for item in schedule["plans"]], + ) + + def status(self, snapshot_path: Path) -> dict[str, Any]: + supplied = normalize_snapshot(read_contract(expand_path(snapshot_path))) + snapshot = _matching_started_snapshot(self.scheduler.run_dir, supplied) + try: + with workflow_lock(self.scheduler.run_dir): + ensure_workflow_events_file(self.scheduler.run_dir) + run = _write_projection(self.scheduler.run_dir, snapshot) + except OSError as exc: + raise _workflow_io_error("status", exc) from exc + return _result("status", str(snapshot["workflow_id"]), run["status"]) + + def resume(self, snapshot_path: Path) -> dict[str, Any]: + snapshot_file = expand_path(snapshot_path) + supplied = normalize_snapshot(read_contract(snapshot_file)) + run_dir = self.scheduler.run_dir + try: + snapshot = _matching_started_snapshot(run_dir, supplied) + ensure_workflow_events_file(run_dir) + except OSError as exc: + raise _workflow_io_error("resume", exc) from exc + schedule = self.scheduler.launch_eligible(snapshot) + try: + with workflow_lock(run_dir): + _write_projection(run_dir, snapshot) + except OSError as exc: + raise _workflow_io_error("resume", exc) from exc + return _result( + "resume", + str(snapshot["workflow_id"]), + {"run_count": len(schedule["plans"]), "plans": schedule["plans"], "results": schedule["results"]}, + snapshot_path=str(workflow_snapshot_path(run_dir)), + events_path=str(workflow_events_path(run_dir)), + status_path=str(workflow_status_path(run_dir)), + run_path=str(workflow_run_path(run_dir)), + started=False, + resumed=True, + scheduled=[str(item["node_id"]) for item in schedule["plans"]], + ) + + def seal(self, snapshot_path: Path) -> dict[str, Any]: + supplied = normalize_snapshot(read_contract(expand_path(snapshot_path))) + snapshot = _matching_started_snapshot(self.scheduler.run_dir, supplied) + evidence = seal_workflow(settings=self.scheduler.settings, run_dir=self.scheduler.run_dir) + return _result( + "seal", + str(snapshot["workflow_id"]), + { + "receipt_sha256": evidence["receipt_sha256"], + "verified": evidence["verified"], + "workflow_state": evidence["receipt"]["workflow_state"], + }, + receipt_path=evidence["receipt_path"], + ) + + def verify(self, snapshot_path: Path) -> dict[str, Any]: + supplied = normalize_snapshot(read_contract(expand_path(snapshot_path))) + snapshot = _matching_started_snapshot(self.scheduler.run_dir, supplied) + evidence = verify_workflow_receipt( + settings=self.scheduler.settings, run_dir=self.scheduler.run_dir + ) + return _result( + "verify", + str(snapshot["workflow_id"]), + { + "receipt_sha256": evidence["receipt_sha256"], + "verified": evidence["verified"], + "workflow_state": evidence["receipt"]["workflow_state"], + }, + receipt_path=evidence["receipt_path"], + ) diff --git a/src/agent_workflow/workflow_templates.py b/src/agent_workflow/workflow_templates.py new file mode 100644 index 0000000..0081641 --- /dev/null +++ b/src/agent_workflow/workflow_templates.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Mapping + +from .errors import WorkflowError +from .workflow import normalize_snapshot + +AUTHORIZED_TEMPLATES = ( + "pipeline", + "parallel-review-fan-in", + "implementation-independent-review", +) + + +def _task(value: Any, *, label: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise WorkflowError(f"{label} must be a task node mapping") + result = deepcopy(dict(value)) + result.pop("dependencies", None) + result.pop("kind", None) + result["kind"] = "task" + return result + + +def expand_workflow_template( + template: str, + *, + workflow_id: str, + pack_id: str, + pack_manifest_sha256: str, + parameters: Mapping[str, Any], +) -> dict[str, Any]: + """Expand one of the three authorized graph shapes deterministically.""" + if template not in AUTHORIZED_TEMPLATES: + raise WorkflowError( + "unsupported workflow template; choose: " + ", ".join(AUTHORIZED_TEMPLATES) + ) + if not isinstance(parameters, Mapping): + raise WorkflowError("workflow template parameters must be a mapping") + nodes: list[dict[str, Any]] + if template == "pipeline": + raw_steps = parameters.get("steps") + if not isinstance(raw_steps, list) or not raw_steps: + raise WorkflowError("pipeline template requires a non-empty steps list") + nodes = [] + previous: str | None = None + for index, raw in enumerate(raw_steps): + node = _task(raw, label=f"steps[{index}]") + node["dependencies"] = [] if previous is None else [previous] + previous = str(node.get("node_id", "")) + nodes.append(node) + elif template == "parallel-review-fan-in": + subject = _task(parameters.get("subject"), label="subject") + raw_reviews = parameters.get("reviews") + if not isinstance(raw_reviews, list) or len(raw_reviews) < 2: + raise WorkflowError("parallel-review-fan-in requires at least two reviews") + reviews = [ + _task(raw, label=f"reviews[{index}]") + for index, raw in enumerate(raw_reviews) + ] + fan_in = _task(parameters.get("fan_in"), label="fan_in") + subject["dependencies"] = [] + subject_id = str(subject.get("node_id", "")) + for review in reviews: + review["dependencies"] = [subject_id] + fan_in["dependencies"] = sorted(str(review.get("node_id", "")) for review in reviews) + nodes = [subject, *reviews, fan_in] + else: + implementation = _task(parameters.get("implementation"), label="implementation") + review = _task(parameters.get("review"), label="review") + implementation["dependencies"] = [] + review["dependencies"] = [str(implementation.get("node_id", ""))] + nodes = [implementation, review] + return normalize_snapshot( + { + "workflow_id": workflow_id, + "pack_id": pack_id, + "pack_manifest_sha256": pack_manifest_sha256, + "nodes": nodes, + } + ) diff --git a/tests/run_fixtures.py b/tests/run_fixtures.py index 7aec7d7..82c3a97 100644 --- a/tests/run_fixtures.py +++ b/tests/run_fixtures.py @@ -93,6 +93,9 @@ def write_run_contracts( "log_path": str(root / "output.log"), "completion_collection_path": str(root / "collections" / "completion.json"), "completion_validation_status": "valid", + "tier": "medium", + "executor": None, + "evaluation_path": None, } atomic_write_json(root / "status.json", status) if include_final: diff --git a/tests/test_approval.py b/tests/test_approval.py new file mode 100644 index 0000000..aef7a51 --- /dev/null +++ b/tests/test_approval.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path + +from agent_workflow.approval import accepted_lifecycle_receipt, is_approved +from agent_workflow.config import defaults +from agent_workflow.errors import WorkflowError +from agent_workflow.ledger import build_ledger +from agent_workflow.scheduler import SchedulerService +from agent_workflow.lifecycle import record +from agent_workflow.util import atomic_write_json +from agent_workflow.workflow import ( + normalize_snapshot, + record_workflow_binding, + record_workflow_transition, + snapshot_sha256, +) +from run_fixtures import write_run_contracts + + +def _completed_run(root: Path, session_id: str) -> Path: + run = root / "state" / "runs" / session_id + write_run_contracts(run, session_id=session_id) + completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) + completion.update(result="completed", head_revision="abc123", unresolved=[]) + atomic_write_json(run / "completion.json", completion) + atomic_write_json( + run / "evaluation-runtime.json", + { + "schema": "agent-workflow/evaluation-runtime/v1", + "scorers": ["acceptance_commands"], + }, + ) + from agent_workflow.receipts import seal_run + + seal_run(run, session_id=session_id) + status = json.loads((run / "status.json").read_text(encoding="utf-8")) + status.update( + status="completed", + disposition=None, + final_receipt_path=str(run / "final-receipt.json"), + final_receipt_sha256=hashlib.sha256( + (run / "final-receipt.json").read_bytes() + ).hexdigest(), + tier="medium", + ) + atomic_write_json(run / "status.json", status) + scores = run / "scores" + scores.mkdir() + score_receipts = [] + for scorer_id in ("schema_validity", "acceptance_commands"): + score_receipt = { + "schema": "agent-workflow/score-receipt/v1", + "scorer": {"id": scorer_id, "version": "1"}, + "final_receipt_sha256": status["final_receipt_sha256"], + "verdict": "pass", + "facts": {}, + "evidence": [], + } + encoded = json.dumps( + score_receipt, sort_keys=True, separators=(",", ":") + ).encode() + digest = hashlib.sha256(encoded).hexdigest() + atomic_write_json(scores / f"{scorer_id}-{digest}.json", score_receipt, mode=0o444) + score_receipts.append(score_receipt) + atomic_write_json( + scores / "score-set.json", + { + "schema": "agent-workflow/score-set/v1", + "final_receipt_sha256": status["final_receipt_sha256"], + "verdict": "pass", + "scores": score_receipts, + }, + ) + return run + + +class ApprovalGateTests(unittest.TestCase): + def test_accepted_receipt_satisfies_approval(self): + with tempfile.TemporaryDirectory() as tmp: + run = _completed_run(Path(tmp), "approval-accepted") + settings = type("S", (), {"state_root": Path(tmp) / "state"})() + record( + settings, + "approval-accepted", + action="reviewed", + actor="reviewer", + reason="review", + ) + record( + settings, + "approval-accepted", + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + receipt = accepted_lifecycle_receipt(run) + self.assertEqual(receipt["action"], "accepted") + self.assertTrue(is_approved(run)) + + def test_lifecycle_receipt_root_symlink_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run = _completed_run(root, "approval-root-symlink") + outside = root / "outside-receipts" + outside.mkdir() + os.symlink(outside, run / "receipts") + settings = type("S", (), {"state_root": root / "state"})() + with self.assertRaisesRegex(WorkflowError, "receipt root is unsafe"): + record( + settings, + "approval-root-symlink", + action="reviewed", + actor="reviewer", + reason="review", + ) + + def test_rejected_tampered_unrelated_and_stale_receipts_fail_closed(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run = _completed_run(root, "approval-primary") + settings = type("S", (), {"state_root": root / "state"})() + record( + settings, + "approval-primary", + action="rejected", + actor="reviewer", + reason="reject", + ) + self.assertFalse(is_approved(run)) + + record( + settings, + "approval-primary", + action="reviewed", + actor="reviewer", + reason="review", + ) + record( + settings, + "approval-primary", + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + receipt_path = run / "receipts" / "000003-accepted.json" + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["final_receipt_sha256"] = "0" * 64 + atomic_write_json(receipt_path, receipt) + self.assertFalse(is_approved(run)) + + other = _completed_run(root, "approval-other") + other_settings = type("S", (), {"state_root": root / "state"})() + record( + other_settings, + "approval-other", + action="reviewed", + actor="reviewer", + reason="review", + ) + record( + other_settings, + "approval-other", + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + status_path = run / "status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + status["lifecycle_receipt_path"] = str( + other / "receipts" / "000002-accepted.json" + ) + atomic_write_json(status_path, status) + self.assertFalse(is_approved(run)) + + stale_status = json.loads(status_path.read_text(encoding="utf-8")) + stale_status["lifecycle_receipt_path"] = str( + run / "receipts" / "000003-accepted.json" + ) + stale_status["final_receipt_sha256"] = "f" * 64 + atomic_write_json(status_path, stale_status) + self.assertFalse(is_approved(run)) + + canonical = _completed_run(root, "approval-canonical") + record( + settings, + "approval-canonical", + action="reviewed", + actor="reviewer", + reason="review", + ) + record( + settings, + "approval-canonical", + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + canonical_status_path = canonical / "status.json" + canonical_status = json.loads( + canonical_status_path.read_text(encoding="utf-8") + ) + canonical_status["lifecycle_receipt_path"] = str( + other / "receipts" / "000002-accepted.json" + ) + canonical_status["final_receipt_sha256"] = "f" * 64 + atomic_write_json(canonical_status_path, canonical_status) + self.assertTrue(is_approved(canonical)) + + def test_downstream_eligibility_follows_receipt_evidence(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack_root = root / "pack" + pack_root.mkdir() + (pack_root / "phase-1").mkdir() + (pack_root / "phase-1" / "task-manifest.yaml").write_text( + "phase: 1\nname: gates\ntasks:\n - id: DEP\n tier: A\n session: approval-dependency\n prompt: tickets/DEP.md\n - id: DOWN\n tier: A\n session: approval-dependent\n prompt: tickets/DOWN.md\n dependencies: [DEP]\n", + encoding="utf-8", + ) + dep_run = _completed_run(root, "approval-dependency") + dep_settings = type("S", (), {"state_root": root / "state"})() + record( + dep_settings, + "approval-dependency", + action="reviewed", + actor="reviewer", + reason="review", + ) + record( + dep_settings, + "approval-dependency", + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + down_run = root / "state" / "runs" / "approval-dependent" + write_run_contracts(down_run, session_id="approval-dependent", include_final=False) + atomic_write_json( + down_run / "status.json", + { + "schema": "agent-workflow/session-status/v2", + "session_id": "approval-dependent", + "status": "missing", + "disposition": None, + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "workdir": str(down_run), + "prompt_path": str(down_run / "prompt.md"), + "log_path": str(down_run / "output.log"), + }, + ) + ledger = build_ledger(pack_root, root / "state" / "runs") + row = next(item for item in ledger["rows"] if item["ticket"] == "DOWN") + self.assertEqual(row["next_action"], "agent-workflow launch approval-dependent ...") + self.assertTrue(is_approved(dep_run)) + + def test_scheduler_advances_accepted_gate_and_launches_downstream(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(), state_root=root / "state") + subject_run = _completed_run(root, "approval-subject") + record(settings, "approval-subject", action="reviewed", actor="reviewer", reason="review") + record( + settings, + "approval-subject", + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + workflow_dir = root / "workflow" + workflow_dir.mkdir() + snapshot = normalize_snapshot( + { + "workflow_id": "accepted-gate-workflow", + "pack_id": "pack", + "pack_manifest_sha256": "a" * 64, + "nodes": [ + { + "node_id": "subject", + "session_id": "approval-subject", + "prompt_path": "subject.md", + "dependencies": [], + }, + { + "node_id": "gate", + "kind": "approval", + "approval_for": "subject", + "dependencies": ["subject"], + }, + { + "node_id": "downstream", + "session_id": "approval-downstream", + "prompt_path": "downstream.md", + "dependencies": ["gate"], + }, + ], + } + ) + digest = snapshot_sha256(snapshot) + record_workflow_binding( + workflow_dir, + workflow_id=snapshot["workflow_id"], + node_id="subject", + run_id="approval-subject", + attempt=1, + actor="scheduler", + reason="launch", + snapshot_sha256=digest, + ) + record_workflow_transition( + workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", + actor="scheduler", reason="running", snapshot_sha256=digest, + previous_state="eligible", next_state="running", + ) + record_workflow_transition( + workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", + actor="scheduler", reason="completed", snapshot_sha256=digest, + previous_state="running", next_state="completed", + ) + launched = [] + + def launch(node, run_id): + launched.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + scheduler = SchedulerService( + settings=settings, + run_dir=workflow_dir, + workdir=root, + launch_fn=launch, + ) + result = scheduler.launch_eligible(snapshot) + status = scheduler.status(snapshot) + states = {item["node_id"]: item["state"] for item in status["nodes"]} + self.assertEqual(states["gate"], "completed") + self.assertEqual(states["downstream"], "running") + self.assertEqual(launched, ["approval-downstream"]) + self.assertEqual(result["plans"][0]["node_id"], "downstream") + gate = next(item for item in status["nodes"] if item["node_id"] == "gate") + self.assertEqual( + gate["approval_receipt_sha256"], + hashlib.sha256((subject_run / "receipts" / "000002-accepted.json").read_bytes()).hexdigest(), + ) + + def test_scheduler_rejected_or_tampered_gate_fails_downstream_closed(self): + for disposition in ("rejected", "tampered"): + with self.subTest(disposition=disposition), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(), state_root=root / "state") + subject_run = _completed_run(root, "approval-subject") + if disposition == "rejected": + record(settings, "approval-subject", action="rejected", actor="reviewer", reason="reject") + else: + record(settings, "approval-subject", action="reviewed", actor="reviewer", reason="review") + record( + settings, "approval-subject", action="accepted", actor="reviewer", + reason="approved", revision="abc123", + ) + receipt_path = subject_run / "receipts" / "000002-accepted.json" + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt["completion_sha256"] = "0" * 64 + atomic_write_json(receipt_path, receipt) + workflow_dir = root / "workflow" + workflow_dir.mkdir() + snapshot = normalize_snapshot( + { + "workflow_id": f"{disposition}-gate-workflow", + "pack_id": "pack", + "pack_manifest_sha256": "b" * 64, + "nodes": [ + {"node_id": "subject", "session_id": "approval-subject", "prompt_path": "subject.md", "dependencies": []}, + {"node_id": "gate", "kind": "approval", "approval_for": "subject", "dependencies": ["subject"]}, + {"node_id": "downstream", "session_id": "approval-downstream", "prompt_path": "downstream.md", "dependencies": ["gate"]}, + ], + } + ) + digest = snapshot_sha256(snapshot) + record_workflow_binding( + workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", + run_id="approval-subject", attempt=1, actor="scheduler", reason="launch", + snapshot_sha256=digest, + ) + record_workflow_transition( + workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", + actor="scheduler", reason="running", snapshot_sha256=digest, + previous_state="eligible", next_state="running", + ) + record_workflow_transition( + workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", + actor="scheduler", reason="completed", snapshot_sha256=digest, + previous_state="running", next_state="completed", + ) + scheduler = SchedulerService( + settings=settings, run_dir=workflow_dir, workdir=root, + launch_fn=lambda node, run_id: self.fail("downstream must not launch"), + ) + result = scheduler.launch_eligible(snapshot) + states = {item["node_id"]: item["state"] for item in scheduler.status(snapshot)["nodes"]} + self.assertEqual(states["gate"], "failed") + self.assertEqual(states["downstream"], "failed") + self.assertEqual(result["plans"], []) diff --git a/tests/test_bindings.py b/tests/test_bindings.py new file mode 100644 index 0000000..d9c9c72 --- /dev/null +++ b/tests/test_bindings.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from agent_workflow.bindings import resolve_json_pointer, resolve_node_inputs +from agent_workflow.config import defaults +from agent_workflow.errors import WorkflowError +from agent_workflow.receipts import seal_run +from agent_workflow.util import atomic_write_json, sha256_file +from agent_workflow.workflow import initial_status, normalize_snapshot +from run_fixtures import write_run_contracts + + +def _sealed_result_run(settings, run_id: str, value: dict[str, object]) -> Path: + run = settings.state_root / "runs" / run_id + write_run_contracts(run, session_id=run_id) + atomic_write_json(run / "result.json", value) + digest = sha256_file(run / "result.json") + atomic_write_json( + run / "collections" / "task-result.json", + { + "schema": "agent-workflow/task-result-collection/v1", + "session_id": run_id, + "required": True, + "schema_path": "result.schema.json", + "source_path": "handoff/result.json", + "source_sha256": digest, + "stored_path": "result.json", + "stored_sha256": digest, + "validation_status": "valid", + "validation_errors": [], + "collected_at": "2026-07-24T00:00:00+00:00", + }, + ) + seal_run(run, session_id=run_id) + return run + + +def _binding_fixture(root: Path): + settings = replace(defaults(), state_root=root / "state") + _sealed_result_run(settings, "source-run", {"nested": {"answer": 42}, "items": ["a", "b"]}) + snapshot = normalize_snapshot( + { + "workflow_id": "binding-workflow", + "pack_id": "pack", + "pack_manifest_sha256": "a" * 64, + "nodes": [ + { + "node_id": "source", + "session_id": "source-run", + "prompt_path": "source.md", + "dependencies": [], + }, + { + "node_id": "target", + "session_id": "target-run", + "prompt_path": "target.md", + "dependencies": ["source"], + "input_bindings": { + "answer": { + "source_node_id": "source", + "pointer": "/nested/answer", + "required": True, + "max_bytes": 128, + } + }, + }, + ], + } + ) + status = initial_status(snapshot) + states = {item["node_id"]: item for item in status["nodes"]} + states["source"].update(state="completed", run_id="source-run", attempt=1) + states["target"]["state"] = "eligible" + node = next(item for item in snapshot["nodes"] if item["node_id"] == "target") + return settings, snapshot, status, node + + +class WorkflowBindingTests(unittest.TestCase): + def test_json_pointer_rejects_invalid_escapes_and_array_aliases(self): + with self.assertRaisesRegex(WorkflowError, "invalid escape"): + resolve_json_pointer({"a": 1}, "/~2") + self.assertIsNot(resolve_json_pointer(["zero"], "/00"), "zero") + self.assertEqual(resolve_json_pointer({"a/b": {"~": 7}}, "/a~1b/~0"), 7) + + def test_binding_snapshot_replay_is_idempotent_and_preserves_creation_time(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings, snapshot, status, node = _binding_fixture(root) + workflow_dir = root / "workflow" + with patch("agent_workflow.bindings.utc_now", return_value="2026-07-24T01:00:00+00:00"): + first = resolve_node_inputs( + snapshot=snapshot, + status=status, + node=node, + settings=settings, + workflow_run_dir=workflow_dir, + attempt=1, + ) + with patch("agent_workflow.bindings.utc_now", return_value="2099-01-01T00:00:00+00:00"): + second = resolve_node_inputs( + snapshot=snapshot, + status=status, + node=node, + settings=settings, + workflow_run_dir=workflow_dir, + attempt=1, + ) + assert first is not None and second is not None + self.assertEqual(first["sha256"], second["sha256"]) + self.assertEqual(second["artifact"]["created_at"], "2026-07-24T01:00:00+00:00") + self.assertEqual(second["artifact"]["bindings"][0]["value"], 42) + self.assertEqual(0, Path(second["path"]).stat().st_mode & 0o222) + + def test_existing_binding_snapshot_must_be_regular_read_only_and_match_sources(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings, snapshot, status, node = _binding_fixture(root) + workflow_dir = root / "workflow" + evidence = resolve_node_inputs( + snapshot=snapshot, + status=status, + node=node, + settings=settings, + workflow_run_dir=workflow_dir, + attempt=1, + ) + assert evidence is not None + path = Path(evidence["path"]) + os.chmod(path, 0o644) + with self.assertRaisesRegex(WorkflowError, "read-only"): + resolve_node_inputs( + snapshot=snapshot, + status=status, + node=node, + settings=settings, + workflow_run_dir=workflow_dir, + attempt=1, + ) + + def test_binding_source_must_be_an_ancestor(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings, snapshot, status, node = _binding_fixture(root) + detached = dict(node) + detached["dependencies"] = [] + detached["input_bindings"] = node["input_bindings"] + altered = normalize_snapshot( + { + **snapshot, + "nodes": [ + next(item for item in snapshot["nodes"] if item["node_id"] == "source"), + detached, + ], + } + ) + altered_node = next(item for item in altered["nodes"] if item["node_id"] == "target") + altered_status = initial_status(altered) + states = {item["node_id"]: item for item in altered_status["nodes"]} + states["source"].update(state="completed", run_id="source-run", attempt=1) + with self.assertRaisesRegex(WorkflowError, "predecessor"): + resolve_node_inputs( + snapshot=altered, + status=altered_status, + node=altered_node, + settings=settings, + workflow_run_dir=root / "workflow", + attempt=1, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_parsing.py b/tests/test_cli_parsing.py index dbb48fc..301e056 100644 --- a/tests/test_cli_parsing.py +++ b/tests/test_cli_parsing.py @@ -144,3 +144,49 @@ def test_control_commands_preserve_message_contract_inputs(self): ) self.assertEqual((steer.command, steer.actor, steer.content), ("steer", "parent", "inspect tests")) self.assertEqual((watch.command, watch.after, watch.timeout), ("watch", 7, 1.5)) + + def test_workflow_commands_parse_paths(self): + validate = _parse_args(build_parser(), ["workflow", "validate", "snapshot.json"]) + start = _parse_args(build_parser(), ["workflow", "start", "run", "snapshot.json"]) + status = _parse_args(build_parser(), ["workflow", "status", "run", "snapshot.json"]) + resume = _parse_args(build_parser(), ["workflow", "resume", "run", "snapshot.json"]) + self.assertEqual((validate.command, validate.workflow_command), ("workflow", "validate")) + self.assertEqual(str(start.run_dir), "run") + self.assertEqual(str(status.run_dir), "run") + self.assertEqual(str(resume.snapshot), "snapshot.json") + + def test_workflow_validate_uses_default_service_root(self): + result = { + "schema": "agent-workflow/workflow-node-result/v1", + "workflow_id": "wf-1", + "action": "validate", + "result": {"snapshot_sha256": "abc", "node_count": 1}, + } + with ( + patch("agent_workflow.cli.WorkflowService.validate", return_value=result) as validate, + patch("agent_workflow.cli._print_json") as print_json, + patch("agent_workflow.cli._print_mapping") as print_mapping, + ): + self.assertEqual(main(["workflow", "validate", "snapshot.json", "--json"]), 0) + validate.assert_called_once() + self.assertEqual(str(validate.call_args.args[0]), "snapshot.json") + print_json.assert_called_once_with(result) + print_mapping.assert_not_called() + + def test_workflow_validate_human_output_uses_default_service_root(self): + result = { + "schema": "agent-workflow/workflow-node-result/v1", + "workflow_id": "wf-1", + "action": "validate", + "result": {"snapshot_sha256": "abc", "node_count": 1}, + } + with ( + patch("agent_workflow.cli.WorkflowService.validate", return_value=result) as validate, + patch("agent_workflow.cli._print_json") as print_json, + patch("agent_workflow.cli._print_mapping") as print_mapping, + ): + self.assertEqual(main(["workflow", "validate", "snapshot.json"]), 0) + validate.assert_called_once() + self.assertEqual(str(validate.call_args.args[0]), "snapshot.json") + print_mapping.assert_called_once_with(result) + print_json.assert_not_called() diff --git a/tests/test_eval_reporting.py b/tests/test_eval_reporting.py index 09646f7..44eb9db 100644 --- a/tests/test_eval_reporting.py +++ b/tests/test_eval_reporting.py @@ -33,7 +33,7 @@ def test_report_reads_only_sealed_local_evidence(self): score_receipt, sort_keys=True, separators=(",", ":") ).encode() ).hexdigest() - atomic_write_json(scores / f"schema_validity-{digest}.json", score_receipt) + atomic_write_json(scores / f"schema_validity-{digest}.json", score_receipt, mode=0o444) atomic_write_json( scores / "score-set.json", { @@ -68,7 +68,7 @@ def test_report_rejects_forged_unsealed_evidence(self): digest = hashlib.sha256( json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() - atomic_write_json(scores / f"schema_validity-{digest}.json", receipt) + atomic_write_json(scores / f"schema_validity-{digest}.json", receipt, mode=0o444) atomic_write_json( scores / "score-set.json", { diff --git a/tests/test_eval_trials.py b/tests/test_eval_trials.py index b23cf65..900b19b 100644 --- a/tests/test_eval_trials.py +++ b/tests/test_eval_trials.py @@ -1,9 +1,12 @@ +import hashlib +import json import tempfile import unittest from pathlib import Path from agent_workflow.eval.trials import collect_trials, extract_trial, load_trials from agent_workflow.metrics import write_execution_evidence +from agent_workflow.provider_evidence import write_provider_evidence from agent_workflow.receipts import seal_run from agent_workflow.util import atomic_write_json from run_fixtures import write_run_contracts @@ -13,12 +16,46 @@ class TrialEvidenceTests(unittest.TestCase): def _run(self, root: Path, name: str) -> Path: run = root / name write_run_contracts(run, session_id=name) + (run / "executor-events.jsonl").write_text( + '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2,"provider_billed_cost":0.1,"currency":"USD"}}\n', + encoding="utf-8", + ) + provider = write_provider_evidence(run, stream_format="codex-jsonl", executor="codex") provenance = __import__("json").loads((run / "run-provenance.json").read_text()) - provenance["usage"] = {"input_tokens": 3, "output_tokens": 2, "cost": 0.1, "currency": "USD"} + provenance["usage"] = provider["aggregate"] + provenance["provider_evidence"] = { + "path": "provider-evidence.json", + "sha256": __import__("hashlib").sha256((run / "provider-evidence.json").read_bytes()).hexdigest(), + "usage_complete": True, + "capture_complete": True, + } atomic_write_json(run / "run-provenance.json", provenance) write_execution_evidence(run, elapsed_seconds=1.5) seal_run(run, session_id=name) - atomic_write_json(run / "scores" / "score-set.json", {"verdict": "pass"}) + final_hash = hashlib.sha256((run / "final-receipt.json").read_bytes()).hexdigest() + score = { + "schema": "agent-workflow/score-receipt/v1", + "scorer": {"id": "schema_validity", "version": "1"}, + "final_receipt_sha256": final_hash, + "verdict": "pass", + "facts": {"contracts": ["completion", "provenance"]}, + "evidence": [ + {"path": "completion.json", "sha256": hashlib.sha256((run / "completion.json").read_bytes()).hexdigest()}, + {"path": "run-provenance.json", "sha256": hashlib.sha256((run / "run-provenance.json").read_bytes()).hexdigest()}, + ], + } + encoded = json.dumps(score, sort_keys=True, separators=(",", ":")).encode() + digest = hashlib.sha256(encoded).hexdigest() + atomic_write_json(run / "scores" / f"schema_validity-{digest}.json", score, mode=0o444) + atomic_write_json( + run / "scores" / "score-set.json", + { + "schema": "agent-workflow/score-set/v1", + "final_receipt_sha256": final_hash, + "verdict": "pass", + "scores": [score], + }, + ) return run def test_extract_and_collect_explicit_sealed_trials(self): @@ -27,6 +64,8 @@ def test_extract_and_collect_explicit_sealed_trials(self): trial = extract_trial(run) self.assertEqual(5, trial["tokens"]) self.assertEqual("pass", trial["verdict"]) + self.assertEqual(0.1, trial["provider_billed_cost"]) + self.assertIsNone(trial["local_estimated_cost"]) output = Path(tmp) / "evidence.json" collect_trials([run], output) self.assertEqual([trial], load_trials(output)) @@ -37,3 +76,22 @@ def test_unsealed_run_is_rejected(self): write_run_contracts(run, include_final=False) with self.assertRaisesRegex(Exception, "final receipt"): extract_trial(run) + def test_writable_content_addressed_score_receipt_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + run = self._run(Path(tmp), "trial-writable-score") + score_file = next( + path for path in (run / "scores").glob("schema_validity-*.json") + ) + score_file.chmod(0o644) + with self.assertRaisesRegex(Exception, "read-only"): + extract_trial(run) + + def test_forged_score_set_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + run = self._run(Path(tmp), "trial-forged") + atomic_write_json( + run / "scores" / "score-set.json", + {"verdict": "pass", "scores": []}, + ) + with self.assertRaisesRegex(Exception, "score set"): + extract_trial(run) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 516be6f..3fdd859 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -14,7 +14,13 @@ class LifecycleTests(unittest.TestCase): - def _completed_run(self, root: Path) -> tuple[object, Path]: + def _completed_run( + self, + root: Path, + *, + tier: str = "medium", + executor: str | None = None, + ) -> tuple[object, Path]: settings = defaults(root / "missing.toml") settings = settings.__class__( **{**settings.__dict__, "state_root": root / "state"} @@ -31,14 +37,16 @@ def _completed_run(self, root: Path) -> tuple[object, Path]: "scorers": ["acceptance_commands"], }, ) - seal_run(run, session_id="life-test") status = json.loads((run / "status.json").read_text(encoding="utf-8")) status.update( - status="completed", - disposition=None, + status="completed", disposition=None, tier=tier, executor=executor + ) + atomic_write_json(run / "status.json", status) + atomic_write_json(run / "final-status.json", status) + seal_run(run, session_id="life-test") + status.update( final_receipt_path=str(run / "final-receipt.json"), final_receipt_sha256=sha256_file(run / "final-receipt.json"), - tier="medium", ) atomic_write_json(run / "status.json", status) scores = run / "scores" @@ -57,7 +65,7 @@ def _completed_run(self, root: Path) -> tuple[object, Path]: score_receipt, sort_keys=True, separators=(",", ":") ).encode() digest = hashlib.sha256(encoded).hexdigest() - atomic_write_json(scores / f"{scorer_id}-{digest}.json", score_receipt) + atomic_write_json(scores / f"{scorer_id}-{digest}.json", score_receipt, mode=0o444) score_receipts.append(score_receipt) atomic_write_json( scores / "score-set.json", @@ -94,6 +102,39 @@ def test_review_then_accept_appends_receipts(self): reconstructed = reconstruct_lifecycle(run / "events.jsonl") self.assertEqual(reconstructed["state"]["review"], "accepted") + def test_mutable_status_projection_cannot_change_review_authority(self): + with tempfile.TemporaryDirectory() as tmp: + settings, run = self._completed_run(Path(tmp)) + status_path = run / "status.json" + status = json.loads(status_path.read_text(encoding="utf-8")) + status.update( + status="failed", + tier="critical", + executor="reviewer", + final_receipt_sha256="0" * 64, + ) + atomic_write_json(status_path, status) + reviewed = record( + settings, + "life-test", + action="reviewed", + actor="reviewer", + reason="canonical terminal evidence controls", + ) + receipt = json.loads( + Path(reviewed["lifecycle_receipt"]).read_text(encoding="utf-8") + ) + self.assertTrue(receipt["reviewer_independent"]) + accepted = record( + settings, + "life-test", + action="accepted", + actor="reviewer", + reason="projection tamper ignored", + revision="abc123", + ) + self.assertEqual("accepted", accepted["disposition"]) + def test_accept_rejects_revision_mismatch_and_failed_score(self): with tempfile.TemporaryDirectory() as tmp: settings, run = self._completed_run(Path(tmp)) @@ -128,6 +169,7 @@ def test_accept_rejects_revision_mismatch_and_failed_score(self): / "scores" / f"{changed_receipt['scorer']['id']}-{changed_digest}.json", changed_receipt, + mode=0o444, ) atomic_write_json(score_path, score) with self.assertRaisesRegex(WorkflowError, "passing deterministic"): @@ -142,11 +184,9 @@ def test_accept_rejects_revision_mismatch_and_failed_score(self): def test_high_risk_acceptance_rejects_executor_self_review(self): with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run(Path(tmp)) - status_path = run / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status.update(tier="high", executor="same-actor") - atomic_write_json(status_path, status) + settings, run = self._completed_run( + Path(tmp), tier="high", executor="same-actor" + ) record( settings, "life-test", @@ -166,11 +206,9 @@ def test_high_risk_acceptance_rejects_executor_self_review(self): def test_high_risk_acceptance_rejects_self_review_then_other_acceptor(self): with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run(Path(tmp)) - status_path = run / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status.update(tier="critical", executor="executor-id") - atomic_write_json(status_path, status) + settings, run = self._completed_run( + Path(tmp), tier="critical", executor="executor-id" + ) record( settings, "life-test", diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 514474b..6947674 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -18,8 +18,13 @@ def test_normalization_preserves_unknown_as_null_and_rejects_invalid_numbers(sel { "input_tokens": 10, "cached_input_tokens": None, + "cache_write_input_tokens": None, "output_tokens": 3, + "reasoning_output_tokens": None, "provider_total_tokens": None, + "provider_billed_cost": None, + "local_estimated_cost": None, + "price_catalog_id": None, "cost": None, "currency": None, }, @@ -44,6 +49,26 @@ def test_command_collection_populates_only_verification_duration(self): self.assertEqual(1.0, stages["verification"]["elapsed_seconds"]) self.assertEqual(3.0, stages["total"]["elapsed_seconds"]) + def test_retry_lineage_is_counted_from_sealed_provenance(self): + with tempfile.TemporaryDirectory() as tmp: + run = Path(tmp) / "run" + write_run_contracts(run, session_id="metrics-retry") + provenance = json.loads((run / "run-provenance.json").read_text()) + provenance["retry_of_run_id"] = "metrics-original" + provenance["workflow"] = { + "workflow_id": "wf", + "node_id": "node", + "attempt": 3, + "inputs_path": None, + "inputs_sha256": None, + "routing": None, + } + atomic_write_json(run / "run-provenance.json", provenance) + metrics = write_execution_evidence(run, elapsed_seconds=1.0) + stages = {item["stage"]: item for item in metrics["stages"]} + self.assertEqual(stages["orchestrator"]["retry_count"], 2) + self.assertEqual(stages["total"]["retry_count"], 2) + def test_metrics_include_required_stages_and_control_events_are_sealed_read_only(self): with tempfile.TemporaryDirectory() as tmp: run = Path(tmp) / "run" diff --git a/tests/test_provider_evidence.py b/tests/test_provider_evidence.py new file mode 100644 index 0000000..e5c6088 --- /dev/null +++ b/tests/test_provider_evidence.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path + +from agent_workflow.provider_evidence import build_provider_evidence + + +class ProviderEvidenceTests(unittest.TestCase): + def _build(self, events: list[dict], stream_format: str = "codex-jsonl"): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "executor-events.jsonl" + path.write_text("".join(json.dumps(item) + "\n" for item in events), encoding="utf-8") + return build_provider_evidence( + events_path=path, + stream_format=stream_format, + executor="codex" if stream_format == "codex-jsonl" else "claude", + session_id="evidence-test", + ) + + def test_terminal_replaces_deltas_without_double_counting(self): + evidence = self._build([ + {"usage_mode": "delta", "usage": {"input_tokens": 3, "output_tokens": 2}}, + {"usage_mode": "delta", "usage": {"input_tokens": 4, "output_tokens": 5}}, + {"type": "turn.completed", "usage": {"input_tokens": 7, "output_tokens": 7}}, + ]) + self.assertEqual(7, evidence["aggregate"]["input_tokens"]) + self.assertEqual(7, evidence["aggregate"]["output_tokens"]) + self.assertIn("MIXED_MODES_TERMINAL_AUTHORITATIVE", evidence["incomplete_reasons"]) + + def test_unidentified_duplicate_delta_is_ambiguous_not_silently_counted(self): + event = {"usage_mode": "delta", "usage": {"input_tokens": 2}} + evidence = self._build([event, event]) + self.assertEqual(2, evidence["aggregate"]["input_tokens"]) + self.assertEqual(1, evidence["classified_usage_count"]) + self.assertFalse(evidence["usage_complete"]) + self.assertIn("AMBIGUOUS_DUPLICATE_DELTA_EVENTS", evidence["incomplete_reasons"]) + + def test_distinct_identified_equal_deltas_are_both_counted(self): + evidence = self._build([ + {"event_id": "one", "usage_mode": "delta", "usage": {"input_tokens": 2}}, + {"event_id": "two", "usage_mode": "delta", "usage": {"input_tokens": 2}}, + ]) + self.assertTrue(evidence["usage_complete"]) + self.assertEqual(4, evidence["aggregate"]["input_tokens"]) + self.assertEqual(2, evidence["classified_usage_count"]) + + def test_conflicting_payload_reuse_of_provider_event_id_is_incomplete(self): + evidence = self._build([ + { + "event_id": "usage-1", + "type": "usage", + "usage_mode": "delta", + "usage": {"input_tokens": 2}, + }, + { + "event_id": "usage-1", + "type": "usage", + "usage_mode": "delta", + "usage": {"input_tokens": 9}, + }, + ]) + self.assertFalse(evidence["usage_complete"]) + self.assertIn( + "CONFLICTING_PROVIDER_EVENT_ID", evidence["incomplete_reasons"] + ) + self.assertEqual(2, evidence["aggregate"]["input_tokens"]) + + def test_mixed_nonterminal_modes_are_incomplete_and_null(self): + evidence = self._build([ + {"usage_mode": "delta", "usage": {"input_tokens": 2}}, + {"usage_mode": "cumulative", "usage": {"input_tokens": 2}}, + ]) + self.assertFalse(evidence["usage_complete"]) + self.assertIsNone(evidence["aggregate"]["input_tokens"]) + + def test_cached_reasoning_and_claude_cost_variants(self): + evidence = self._build([ + {"type": "result", "total_cost_usd": 0.25, "usage": { + "input_tokens": 10, + "cache_read_input_tokens": 4, + "cache_creation_input_tokens": 2, + "output_tokens": 3, + "reasoning_tokens": 1, + }} + ], stream_format="claude-stream-json") + aggregate = evidence["aggregate"] + self.assertEqual(4, aggregate["cached_input_tokens"]) + self.assertEqual(2, aggregate["cache_write_input_tokens"]) + self.assertEqual(1, aggregate["reasoning_output_tokens"]) + self.assertEqual(0.25, aggregate["provider_billed_cost"]) + self.assertEqual("USD", aggregate["currency"]) + + def test_empty_terminal_usage_is_incomplete(self): + evidence = self._build([{"type": "turn.completed"}]) + self.assertFalse(evidence["usage_complete"]) + self.assertIn("TERMINAL_USAGE_EMPTY", evidence["incomplete_reasons"]) + + def test_conflicting_terminal_updates_fail_closed(self): + evidence = self._build([ + {"type": "turn.completed", "usage": {"input_tokens": 3}}, + {"type": "turn.completed", "usage": {"input_tokens": 4}}, + ]) + self.assertFalse(evidence["usage_complete"]) + self.assertIsNone(evidence["aggregate"]["input_tokens"]) + self.assertIn("CONFLICTING_TERMINAL_UPDATES", evidence["incomplete_reasons"]) + + def test_non_finite_usage_is_rejected(self): + evidence = self._build([ + {"type": "turn.completed", "usage": {"input_tokens": float("inf")}}, + ]) + self.assertFalse(evidence["usage_complete"]) + self.assertIsNone(evidence["aggregate"]["input_tokens"]) + + def test_provider_cost_without_currency_is_incomplete(self): + evidence = self._build([ + {"type": "turn.completed", "usage": { + "input_tokens": 1, "provider_billed_cost": 0.1 + }}, + ]) + self.assertFalse(evidence["usage_complete"]) + self.assertIn("PROVIDER_COST_MISSING_CURRENCY", evidence["incomplete_reasons"]) + + def test_nonmonotonic_cumulative_usage_fails_closed(self): + evidence = self._build([ + {"usage_mode": "cumulative", "usage": {"input_tokens": 10}}, + {"usage_mode": "cumulative", "usage": {"input_tokens": 9}}, + ]) + self.assertFalse(evidence["usage_complete"]) + self.assertIsNone(evidence["aggregate"]["input_tokens"]) + self.assertIn("NONMONOTONIC_CUMULATIVE_USAGE", evidence["incomplete_reasons"]) + + def test_raw_event_symlink_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + target = root / "target.jsonl" + target.write_text('{"type":"turn.completed","usage":{"input_tokens":1}}\n', encoding="utf-8") + link = root / "executor-events.jsonl" + os.symlink(target, link) + with self.assertRaisesRegex(Exception, "non-symlink"): + build_provider_evidence( + events_path=link, + stream_format="codex-jsonl", + executor="codex", + session_id="evidence-test", + ) + + def test_unknown_cost_remains_null(self): + evidence = self._build([ + {"type": "turn.completed", "usage": {"input_tokens": 1, "output_tokens": 1}} + ]) + self.assertIsNone(evidence["aggregate"]["provider_billed_cost"]) + self.assertIsNone(evidence["aggregate"]["local_estimated_cost"]) + self.assertIsNone(evidence["aggregate"]["currency"]) diff --git a/tests/test_receipts.py b/tests/test_receipts.py index 32f4fbc..79d3940 100644 --- a/tests/test_receipts.py +++ b/tests/test_receipts.py @@ -5,7 +5,7 @@ from pathlib import Path from agent_workflow.errors import WorkflowError -from agent_workflow.receipts import seal_run, verify_seal +from agent_workflow.receipts import make_read_only, seal_run, verify_seal from agent_workflow.util import sha256_file from run_fixtures import write_run_contracts @@ -32,7 +32,7 @@ def test_seal_is_anchored_and_detects_tampering(self): item for item in forged["artifacts"] if item["path"] != "output.log" ] receipt_path.write_text(json.dumps(forged), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "checksum mismatch"): + with self.assertRaisesRegex(WorkflowError, "read-only|checksum mismatch"): verify_seal(root, expected_sha256=expected) receipt_path.unlink() @@ -43,6 +43,58 @@ def test_seal_is_anchored_and_detects_tampering(self): with self.assertRaisesRegex(WorkflowError, "mismatch"): verify_seal(root, expected_sha256=expected) + def test_seal_rejects_symlinked_lock_and_receipt_paths(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_run_contracts(root) + target = root / "target" + target.touch() + os.symlink(target, root / "seal.lock") + with self.assertRaises(WorkflowError): + seal_run(root, session_id="unsafe-lock") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_run_contracts(root) + target = root / "missing-receipt-target" + os.symlink(target, root / "final-receipt.json") + with self.assertRaisesRegex(WorkflowError, "already sealed or unsafe"): + seal_run(root, session_id="unsafe-receipt") + + def test_make_read_only_covers_optional_sealed_trees(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + assignment = root / "assignments" / "child" / "record.json" + assignment.parent.mkdir(parents=True) + assignment.write_text("{}\n", encoding="utf-8") + make_read_only(root) + self.assertEqual(0, assignment.stat().st_mode & 0o222) + + def test_make_read_only_rejects_symlinks_without_chmodding_target(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + outside = root / "outside.json" + outside.write_text("{}\n", encoding="utf-8") + assignments = root / "assignments" + assignments.mkdir() + os.symlink(outside, assignments / "linked.json") + before = outside.stat().st_mode & 0o777 + with self.assertRaisesRegex(WorkflowError, "symlink"): + make_read_only(root) + self.assertEqual(before, outside.stat().st_mode & 0o777) + + def test_verify_rejects_intermediate_symlink_even_when_target_stays_inside_run(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + write_run_contracts(root) + seal_run(root, session_id="intermediate-link") + original = root / "collections" + target = root / "collections-real" + original.rename(target) + os.symlink(target.name, original) + with self.assertRaisesRegex(WorkflowError, "directory|symlink|open"): + verify_seal(root) + def test_seal_rejects_missing_required_contracts(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/test_release_assets.py b/tests/test_release_assets.py index a333c0c..5363fde 100644 --- a/tests/test_release_assets.py +++ b/tests/test_release_assets.py @@ -1,17 +1,28 @@ from __future__ import annotations +import importlib.util import subprocess import unittest +import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +AUDIT_SCRIPT = ROOT / "scripts" / "audit-release-assets.py" + + +def _load_audit_module(): + spec = importlib.util.spec_from_file_location("audit_release_assets", AUDIT_SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module class ReleaseAssetTests(unittest.TestCase): def test_release_asset_audit_passes(self) -> None: result = subprocess.run( - ["python3", str(ROOT / "scripts/audit-release-assets.py")], + ["python3", str(AUDIT_SCRIPT)], cwd=ROOT, text=True, capture_output=True, @@ -19,6 +30,33 @@ def test_release_asset_audit_passes(self) -> None: ) self.assertEqual(result.returncode, 0, result.stderr) + def test_release_files_excludes_git_control_paths(self) -> None: + module = _load_audit_module() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "keep.txt").write_text("keep", encoding="utf-8") + + git_dir = root / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("config", encoding="utf-8") + (git_dir / "objects").mkdir() + (git_dir / "objects" / "obj").write_text("obj", encoding="utf-8") + + self.assertEqual( + [path.relative_to(root).as_posix() for path in module.release_files(root)], + ["keep.txt"], + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "keep.txt").write_text("keep", encoding="utf-8") + (root / ".git").write_text("gitdir: /tmp/repo/.git/worktrees/demo\n", encoding="utf-8") + + self.assertEqual( + [path.relative_to(root).as_posix() for path in module.release_files(root)], + ["keep.txt"], + ) + def test_every_skill_has_yaml_frontmatter(self) -> None: for path in sorted((ROOT / "skills").glob("*/SKILL.md")): text = path.read_text(encoding="utf-8") diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..31e7071 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import unittest +from dataclasses import replace + +from agent_workflow.config import AgentClassPolicy, ExecutorPolicy, defaults +from agent_workflow.errors import WorkflowError +from agent_workflow.routing import advise_routing + + +class RoutingAdviceTests(unittest.TestCase): + def test_task_types_route_to_stable_agent_classes(self): + settings = defaults() + cases = { + "research": ("exploratory", "TASK_EXPLORATORY"), + "discovery": ("exploratory", "TASK_EXPLORATORY"), + "review": ("review", "TASK_REVIEW"), + "security": ("review", "TASK_REVIEW"), + "implementation": ("implementation", "TASK_IMPLEMENTATION"), + "unknown": ("implementation", "TASK_IMPLEMENTATION"), + } + for task_type, (agent_class, code) in cases.items(): + with self.subTest(task_type=task_type): + advice = advise_routing({"task_type": task_type}, settings) + self.assertEqual(advice["recommendation"]["agent_class"], agent_class) + self.assertIn(code, advice["explanation_codes"]) + + def test_interactivity_metadata_is_advisory_and_explained(self): + settings = defaults() + required = advise_routing( + {"task_type": "research", "requires_interaction": True}, settings + ) + self.assertTrue(required["recommendation"]["interactive"]) + self.assertIn("INTERACTION_REQUIRED", required["explanation_codes"]) + disabled = advise_routing( + {"task_type": "implementation", "requires_interaction": False}, settings + ) + self.assertFalse(disabled["recommendation"]["interactive"]) + self.assertIn("INTERACTION_NOT_REQUIRED", disabled["explanation_codes"]) + + def test_enforced_selection_and_policy_disagreement_are_separate(self): + advice = advise_routing( + {"task_type": "review"}, + defaults(), + enforced_selection={"executor": "claude", "model": "sonnet"}, + ) + self.assertEqual(advice["recommendation"]["executor"], "codex") + self.assertEqual(advice["enforced_selection"]["executor"], "claude") + self.assertEqual(advice["policy_disagreements"], ["executor", "model"]) + self.assertIn("ENFORCED_SELECTION_DIFFERS", advice["explanation_codes"]) + + def test_high_risk_implementation_has_stable_explanation(self): + advice = advise_routing( + {"task_type": "implementation", "risk": "critical"}, defaults() + ) + self.assertIn("RISK_HIGH_IMPLEMENTATION", advice["explanation_codes"]) + + def test_no_go_only_model_policy_is_rejected(self): + base = defaults() + settings = replace( + base, + executor_policies={ + **base.executor_policies, + "codex": ExecutorPolicy( + interactive_command=["codex"], + models=("blocked-model",), + default_model="blocked-model", + no_go_models=("blocked-*",), + ), + }, + agent_classes={ + **base.agent_classes, + "review": AgentClassPolicy( + interactive=False, + default_executor="codex", + default_model="blocked-model", + allowed_models={"codex": ("blocked-model",)}, + ), + }, + ) + with self.assertRaisesRegex(WorkflowError, "no non-no-go model"): + advise_routing({"task_type": "review"}, settings) + + def test_input_metadata_is_not_mutated_and_output_is_deterministic(self): + metadata = {"task_type": "research", "requires_interaction": False} + first = advise_routing(metadata, defaults()) + second = advise_routing(metadata, defaults()) + self.assertEqual(first, second) + self.assertEqual(metadata, {"task_type": "research", "requires_interaction": False}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runner_generation.py b/tests/test_runner_generation.py index fc2111d..63134cd 100644 --- a/tests/test_runner_generation.py +++ b/tests/test_runner_generation.py @@ -308,9 +308,10 @@ def test_runner_enforces_reported_token_and_cost_budgets(self): provenance_path.write_text(json.dumps(provenance), encoding="utf-8") event = json.dumps( { + "type": "turn.completed", "usage": { "output_tokens": 2, - "cost": 0.2, + "provider_billed_cost": 0.2, "currency": "USD", } } diff --git a/tests/test_session_launch.py b/tests/test_session_launch.py index 96cae63..2269ec1 100644 --- a/tests/test_session_launch.py +++ b/tests/test_session_launch.py @@ -111,6 +111,43 @@ def test_agent_name_is_unique_across_interactive_and_detached_runs(self): agent_name="agent-42", interactive=True, ) + def test_workflow_inputs_are_copied_read_only_before_executor_launch(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workdir = root / "worktree" + workdir.mkdir() + prompt = root / "task.md" + prompt.write_text("task", encoding="utf-8") + settings = defaults(root / "missing.toml") + settings = settings.__class__( + **{**settings.__dict__, "state_root": root / "state"} + ) + context = { + "schema": "agent-workflow/workflow-input-bindings/v1", + "workflow_id": "wf-1", + "snapshot_sha256": "a" * 64, + "node_id": "node-1", + "attempt": 1, + "created_at": "2026-07-24T00:00:00+00:00", + "bindings": [], + } + with ( + patch("agent_workflow.sessions.tmux.session_exists", return_value=False), + patch("agent_workflow.sessions.tmux.create_session"), + patch("agent_workflow.sessions.tmux.pane_info", return_value=None), + ): + result = launch( + settings, + session_id="workflow-input-copy", + workdir=workdir, + prompt_path=prompt, + explicit_command=["cat"], + workflow_context=context, + ) + copied = Path(result["prompt_path"]).parent / "workflow-inputs.json" + self.assertEqual(context, json.loads(copied.read_text(encoding="utf-8"))) + self.assertEqual(0, copied.stat().st_mode & 0o222) + def test_named_agent_profile_binds_executor_model_and_class(self): base = defaults(Path("/missing.toml")) settings = base.__class__( @@ -328,6 +365,8 @@ def test_native_job_binding_snapshots_raw_bytes_and_status_provenance(self): self.assertEqual(stored.read_bytes(), job.read_bytes()) self.assertEqual(binding["job_source_sha256"], hashlib.sha256(job.read_bytes()).hexdigest()) self.assertEqual(binding["job_stored_sha256"], binding["job_source_sha256"]) + self.assertEqual(0, stored.stat().st_mode & 0o222) + self.assertEqual(0, (run / "job-binding.json").stat().st_mode & 0o222) self.assertEqual(binding["path_policy"]["allowed_paths"], ["src/example.py"]) self.assertTrue(binding["review_requirement"]["required"]) status = json.loads((run / "status.json").read_text(encoding="utf-8")) diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..f59a9ae --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,1702 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import threading +import time +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from agent_workflow.contracts import load_schema +from agent_workflow.errors import WorkflowError +from agent_workflow.receipts import seal_run +from agent_workflow.util import atomic_write_json +from agent_workflow.workflow import ( + WORKFLOW_EVENT_SCHEMA, + WORKFLOW_NODE_BINDING_SCHEMA, + WORKFLOW_NODE_RESULT_SCHEMA, + WORKFLOW_RUN_SCHEMA, + WORKFLOW_SNAPSHOT_SCHEMA, + WORKFLOW_STATUS_SCHEMA, + append_workflow_event, + build_workflow_run, + initial_status, + normalize_snapshot, + record_workflow_binding, + record_workflow_transition, + reconstruct_workflow_status, + snapshot_sha256, + workflow_events_path, + workflow_lock, + workflow_run_path, + workflow_snapshot_path, + workflow_status_path, + write_workflow_projection, +) +from agent_workflow.scheduler import SchedulerService, calculate_eligibility, plan_launches +from agent_workflow.config import defaults +from agent_workflow.workflow_service import WorkflowService +from run_fixtures import write_run_contracts + + +class WorkflowContractTests(unittest.TestCase): + def test_workflow_schemas_are_discoverable(self) -> None: + for schema_id in ( + WORKFLOW_SNAPSHOT_SCHEMA, + WORKFLOW_NODE_BINDING_SCHEMA, + WORKFLOW_NODE_RESULT_SCHEMA, + WORKFLOW_EVENT_SCHEMA, + WORKFLOW_STATUS_SCHEMA, + WORKFLOW_RUN_SCHEMA, + ): + self.assertEqual(load_schema(schema_id)["$id"], schema_id) + + +class WorkflowServiceErrorMappingTests(unittest.TestCase): + def test_start_wraps_filesystem_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + snapshot = root / "snapshot.json" + snapshot.write_text(json.dumps(self._snapshot_data()), encoding="utf-8") + service = WorkflowService( + scheduler=SchedulerService( + settings=defaults(root / "config.toml"), + run_dir=root / "run", + workdir=root, + launch_fn=lambda *_: {"run_id": "a"}, + ) + ) + with patch("agent_workflow.workflow_service.atomic_write_json", side_effect=OSError("boom")): + with self.assertRaisesRegex(WorkflowError, "workflow start failed"): + service.start(snapshot) + + def test_resume_wraps_filesystem_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + snapshot = root / "snapshot.json" + snapshot.write_text(json.dumps(self._snapshot_data()), encoding="utf-8") + run_dir = root / "run" + run_dir.mkdir() + (run_dir / "workflow-run.json").write_text("{}", encoding="utf-8") + stored_snapshot = run_dir / "workflow-snapshot.json" + stored_snapshot.write_text( + snapshot.read_text(encoding="utf-8"), encoding="utf-8" + ) + stored_snapshot.chmod(0o444) + settings = replace( + defaults(root / "config.toml"), state_root=root / "state" + ) + + def launch(_node, run_id): + write_run_contracts( + settings.state_root / "runs" / run_id, session_id=run_id + ) + return {"run_id": run_id} + + service = WorkflowService( + scheduler=SchedulerService( + settings=settings, + run_dir=run_dir, + workdir=root, + launch_fn=launch, + ) + ) + with patch("agent_workflow.workflow.atomic_write_json", side_effect=OSError("boom")): + with self.assertRaisesRegex(WorkflowError, "workflow resume failed"): + service.resume(snapshot) + + @staticmethod + def _snapshot_data() -> dict[str, object]: + return { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": "wf-service", + "pack_id": "pack", + "pack_manifest_sha256": "a" * 64, + "nodes": [ + { + "node_id": "A", + "session_id": "a", + "prompt_path": "a.md", + "dependencies": [], + } + ], + } + + +class SchedulerServiceTests(unittest.TestCase): + def _snapshot(self) -> dict[str, object]: + return { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": "wf-scheduler", + "pack_id": "pack", + "pack_manifest_sha256": "a" * 64, + "nodes": [ + {"node_id": "A", "session_id": "a", "prompt_path": "a.md", "dependencies": []}, + {"node_id": "B", "session_id": "b", "prompt_path": "b.md", "dependencies": []}, + {"node_id": "C", "session_id": "c", "prompt_path": "c.md", "dependencies": ["A"]}, + ], + } + + def test_eligibility_is_bounded_and_failed_prerequisite_blocks_dependent(self): + snapshot = normalize_snapshot(self._snapshot()) + status = initial_status(snapshot) + self.assertEqual(calculate_eligibility(snapshot, status), ["A", "B"]) + self.assertEqual(plan_launches(snapshot, status, max_parallelism=1), ["A"]) + status["nodes"][0]["state"] = "failed" + self.assertEqual(calculate_eligibility(snapshot, status), ["B"]) + + def test_running_nodes_consume_parallelism_capacity(self): + snapshot = normalize_snapshot(self._snapshot()) + status = initial_status(snapshot) + by_id = {item["node_id"]: item for item in status["nodes"]} + by_id["A"].update(state="running", run_id="a", attempt=1) + self.assertEqual(plan_launches(snapshot, status, max_parallelism=1), []) + self.assertEqual(plan_launches(snapshot, status, max_parallelism=2), ["B"]) + + def test_launch_is_idempotent_on_replay_and_parallelism_is_bounded(self): + snapshot = normalize_snapshot(self._snapshot()) + snapshot["nodes"] = [snapshot["nodes"][0]] + snapshot = normalize_snapshot(snapshot) + active = 0 + maximum = 0 + calls: list[str] = [] + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(node, run_id): + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + calls.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + active -= 1 + return {"run_id": run_id} + + service = SchedulerService( + settings=settings, + run_dir=Path(tmp) / "workflow", + workdir=Path(tmp), + max_parallelism=1, + launch_fn=launch, + ) + first = service.launch_eligible(snapshot) + second = service.launch_eligible(snapshot) + self.assertEqual(len(first["plans"]), 1) + self.assertEqual(second["plans"], []) + self.assertEqual(calls, ["a"]) + self.assertEqual(maximum, 1) + + def test_recoverable_node_can_be_retried_and_replayed(self): + snapshot = normalize_snapshot({**self._snapshot(), "nodes": [self._snapshot()["nodes"][0]]}) + calls: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run_dir = root / "workflow" + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + digest = snapshot_sha256(snapshot) + record_workflow_binding( + run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", + attempt=1, actor="test", reason="bound", snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="running", snapshot_sha256=digest, + previous_state="eligible", next_state="running", + ) + record_workflow_transition( + run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="manual recovery required", snapshot_sha256=digest, + previous_state="running", next_state="recoverable", + ) + + def launch(node, run_id): + calls.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = SchedulerService( + settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch, + ) + result = service.retry(snapshot, "A") + self.assertEqual(result["plan"]["attempt"], 2) + self.assertEqual(result["plan"]["retry_of_run_id"], "a") + self.assertEqual(calls, ["a-retry-2"]) + replayed = service.status(snapshot) + self.assertEqual(replayed["nodes"][0]["state"], "running") + self.assertEqual(replayed["nodes"][0]["attempt"], 2) + + def test_failed_launch_can_be_retried_with_lineage(self): + snapshot = normalize_snapshot(self._snapshot()) + calls: list[str] = [] + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(node, run_id): + calls.append(run_id) + if len(calls) == 1: + raise RuntimeError("synthetic launch failure") + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = SchedulerService( + settings=settings, + run_dir=root / "workflow", + workdir=root, + launch_fn=launch, + ) + with self.assertRaises(RuntimeError): + service.launch_eligible(snapshot) + result = service.retry(snapshot, "A") + self.assertEqual(result["plan"]["attempt"], 2) + self.assertEqual(result["plan"]["retry_of_run_id"], "a") + self.assertEqual(calls, ["a", "a-retry-2"]) + + def test_two_services_serialize_binding_and_external_launch(self): + snapshot = normalize_snapshot(self._snapshot()) + calls: list[str] = [] + calls_lock = threading.Lock() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run_dir = root / "workflow" + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(node, run_id): + with calls_lock: + calls.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + time.sleep(0.05) + return {"run_id": run_id} + + services = [ + SchedulerService(settings=settings, run_dir=run_dir, + workdir=root, launch_fn=launch) + for i in range(2) + ] + threads = [threading.Thread(target=service.launch_eligible, args=(snapshot,)) for service in services] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(calls, ["a"]) + self.assertEqual(services[0].status(snapshot)["nodes"][0]["state"], "running") + + def test_running_event_cannot_circularly_prove_deleted_child_exists(self): + snapshot = normalize_snapshot({**self._snapshot(), "nodes": [self._snapshot()["nodes"][0]]}) + calls: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run_dir = root / "workflow" + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + digest = snapshot_sha256(snapshot) + record_workflow_binding( + run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", + attempt=1, actor="test", reason="bound", snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="authoritative child once existed", snapshot_sha256=digest, + previous_state="eligible", next_state="running", + details={"child_run_id": "a"}, + ) + service = SchedulerService( + settings=settings, run_dir=run_dir, workdir=root, + launch_fn=lambda node, run_id: calls.append(run_id), + ) + service.launch_eligible(snapshot) + self.assertEqual(service.status(snapshot)["nodes"][0]["state"], "recoverable") + self.assertEqual(calls, []) + + def test_resume_reconciles_sealed_child_and_launches_dependent(self): + snapshot = normalize_snapshot( + {**self._snapshot(), "nodes": [self._snapshot()["nodes"][0], self._snapshot()["nodes"][2]]} + ) + launched: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + child = settings.state_root / "runs" / "a" + write_run_contracts(child, session_id="a") + completion = json.loads((child / "completion.json").read_text(encoding="utf-8")) + completion.update(result="completed", head_revision="abc123", unresolved=[]) + atomic_write_json(child / "completion.json", completion) + final_status = json.loads((child / "final-status.json").read_text(encoding="utf-8")) + final_status.update(status="completed") + atomic_write_json(child / "final-status.json", final_status) + seal_run(child, session_id="a") + + run_dir = root / "workflow" + digest = snapshot_sha256(snapshot) + record_workflow_binding( + run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", + attempt=1, actor="test", reason="bound", snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="running", snapshot_sha256=digest, + previous_state="eligible", next_state="running", + details={"child_run_id": "a"}, + ) + + def launch(node, run_id): + launched.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = SchedulerService( + settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch, + ) + service.launch_eligible(snapshot) + status = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} + self.assertEqual(status["A"]["state"], "completed") + self.assertEqual(status["C"]["state"], "running") + self.assertEqual(launched, ["c"]) + + def test_mapping_only_launch_result_is_not_authoritative(self): + snapshot = normalize_snapshot( + {**self._snapshot(), "nodes": [self._snapshot()["nodes"][0]]} + ) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + service = SchedulerService( + settings=settings, + run_dir=root / "workflow", + workdir=root, + launch_fn=lambda node, run_id: {"run_id": run_id}, + ) + with self.assertRaisesRegex(WorkflowError, "no authoritative child run"): + service.launch_eligible(snapshot) + self.assertEqual(service.status(snapshot)["nodes"][0]["state"], "recoverable") + + def test_retry_reopens_dependency_failed_descendants(self): + snapshot = normalize_snapshot( + {**self._snapshot(), "nodes": [self._snapshot()["nodes"][0], self._snapshot()["nodes"][2]]} + ) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + run_dir = root / "workflow" + digest = snapshot_sha256(snapshot) + record_workflow_binding( + run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", + attempt=1, actor="test", reason="bound", snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="running", snapshot_sha256=digest, + previous_state="eligible", next_state="running", + ) + record_workflow_transition( + run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="failed", snapshot_sha256=digest, + previous_state="running", next_state="failed", + ) + launched: list[str] = [] + + def launch(node, run_id): + launched.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = SchedulerService( + settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch, + ) + service.launch_eligible(snapshot) + by_id = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} + self.assertEqual(by_id["C"]["state"], "failed") + + service.retry(snapshot, "A") + service.launch_eligible(snapshot) + by_id = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} + self.assertEqual(by_id["A"]["state"], "running") + self.assertEqual(by_id["C"]["state"], "blocked") + + retry_run = settings.state_root / "runs" / "a-retry-2" + completion = json.loads((retry_run / "completion.json").read_text(encoding="utf-8")) + completion.update(result="completed", head_revision="abc123", unresolved=[]) + atomic_write_json(retry_run / "completion.json", completion) + final_status = json.loads((retry_run / "final-status.json").read_text(encoding="utf-8")) + final_status.update(status="completed") + atomic_write_json(retry_run / "final-status.json", final_status) + seal_run(retry_run, session_id="a-retry-2") + + service.launch_eligible(snapshot) + by_id = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} + self.assertEqual(by_id["A"]["state"], "completed") + self.assertEqual(by_id["C"]["state"], "running") + self.assertEqual(launched, ["a-retry-2", "c"]) + + def test_restart_windows_reuse_binding_and_fail_closed_running_without_child(self): + snapshot = normalize_snapshot(self._snapshot()) + calls: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) / "workflow" + settings = replace(defaults(Path(tmp) / "config.toml"), state_root=Path(tmp) / "state") + digest = snapshot_sha256(snapshot) + record_workflow_binding(run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", + attempt=1, actor="scheduler", reason="crash after binding", snapshot_sha256=digest) + + def launch(node, run_id): + calls.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = SchedulerService(settings=settings, run_dir=run_dir, workdir=Path(tmp), launch_fn=launch) + service.launch_eligible(snapshot) + self.assertEqual(calls, ["a"]) + record_workflow_transition(run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="inject crash after running", snapshot_sha256=digest, + previous_state="running", next_state="recoverable") + self.assertEqual(service.status(snapshot)["nodes"][0]["state"], "recoverable") + + shutil.rmtree(settings.state_root / "runs" / "a") + orphan_dir = Path(tmp) / "orphan-workflow" + orphan_snapshot = normalize_snapshot({**snapshot, "nodes": [snapshot["nodes"][0]]}) + orphan_digest = snapshot_sha256(orphan_snapshot) + record_workflow_binding(orphan_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", + attempt=1, actor="test", reason="seed binding", snapshot_sha256=orphan_digest) + record_workflow_transition(orphan_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="seed running crash window", snapshot_sha256=orphan_digest, + previous_state="eligible", next_state="running") + orphan_service = SchedulerService(settings=settings, run_dir=orphan_dir, workdir=Path(tmp), launch_fn=launch) + orphan_service.launch_eligible(orphan_snapshot) + self.assertEqual(orphan_service.status(orphan_snapshot)["nodes"][0]["state"], "recoverable") + self.assertEqual(calls, ["a"]) + + def test_completed_prerequisite_is_journaled_before_dependent_launch_and_provenance_is_forwarded(self): + snapshot = normalize_snapshot(self._snapshot()) + snapshot["nodes"] = [snapshot["nodes"][0], snapshot["nodes"][2]] + snapshot = normalize_snapshot(snapshot) + run_dir = None + seen: list[dict[str, object]] = [] + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) / "workflow" + digest = snapshot_sha256(snapshot) + record_workflow_binding(run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", attempt=1, + actor="test", reason="A bound", snapshot_sha256=digest) + record_workflow_transition(run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="A running", snapshot_sha256=digest, previous_state="eligible", next_state="running") + record_workflow_transition(run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", + reason="A complete", snapshot_sha256=digest, previous_state="running", next_state="completed") + + settings = replace(defaults(Path(tmp) / "config.toml"), state_root=Path(tmp) / "state") + + def launch(node, run_id): + seen.append(dict(node)) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = SchedulerService(settings=settings, run_dir=run_dir, + workdir=Path(tmp), launch_fn=launch) + service.launch_eligible(snapshot) + self.assertEqual(seen[0]["node_id"], "C") + self.assertEqual(seen[0]["pack_id"], "pack") + self.assertIsNone(seen[0]["retry_of_run_id"]) + events = [json.loads(line) for line in workflow_events_path(run_dir).read_text().splitlines()] + self.assertTrue(any(event.get("previous_state") == "blocked" and event.get("next_state") == "eligible" for event in events)) + + +class WorkflowReplayTests(unittest.TestCase): + @staticmethod + def _snapshot() -> dict[str, object]: + return { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": "wf-contract", + "pack_id": "workflow-foundations-next", + "pack_manifest_sha256": "0" * 64, + "nodes": [ + { + "node_id": "A", + "ticket_id": "WF-00", + "session_id": "wf-00", + "tier": "B", + "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", + "dependencies": [], + }, + { + "node_id": "B", + "ticket_id": "WF-01", + "session_id": "wf-01", + "tier": "A", + "prompt_path": "phase-0/tickets/WF-01-scheduler-service.md", + "dependencies": ["A"], + }, + ], + } + + @staticmethod + def _write_events(path: Path, events: list[dict[str, object]]) -> None: + path.write_text( + "".join(json.dumps(event, sort_keys=True) + "\n" for event in events), + encoding="utf-8", + ) + + def test_snapshot_rejects_unknown_and_cyclic_dependencies(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + unknown = self._snapshot() + unknown["nodes"] = [ + unknown["nodes"][0], + { + "node_id": "C", + "session_id": "wf-02", + "prompt_path": "phase-0/tickets/WF-02-restart-and-cli.md", + "dependencies": ["missing"], + }, + ] + with self.assertRaisesRegex(WorkflowError, "unknown dependency"): + normalize_snapshot(unknown) + + cyclic = self._snapshot() + cyclic["nodes"] = [ + { + "node_id": "A", + "session_id": "wf-00", + "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", + "dependencies": ["B"], + }, + { + "node_id": "B", + "session_id": "wf-01", + "prompt_path": "phase-0/tickets/WF-01-scheduler-service.md", + "dependencies": ["A"], + }, + ] + with self.assertRaisesRegex(WorkflowError, "dependency cycle"): + normalize_snapshot(cyclic) + + def test_snapshot_rejects_duplicate_dependencies_and_session_ids(self) -> None: + duplicate_dependency = self._snapshot() + duplicate_dependency["nodes"][1]["dependencies"] = ["A", "A"] + with self.assertRaisesRegex(WorkflowError, "duplicate dependency"): + normalize_snapshot(duplicate_dependency) + + duplicate_session = self._snapshot() + duplicate_session["nodes"][1]["session_id"] = "wf-00" + with self.assertRaisesRegex(WorkflowError, "duplicate workflow session ID"): + normalize_snapshot(duplicate_session) + + def test_event_journal_rejects_symlink_and_corrupt_existing_content(self) -> None: + snapshot = normalize_snapshot(self._snapshot()) + digest = snapshot_sha256(snapshot) + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) / "symlink" + run_dir.mkdir() + target = Path(tmp) / "target.jsonl" + target.touch() + os.symlink(target, workflow_events_path(run_dir)) + with self.assertRaises(WorkflowError): + record_workflow_binding( + run_dir, workflow_id="wf-contract", node_id="A", run_id="wf-00", + attempt=1, actor="test", reason="bound", snapshot_sha256=digest, + ) + + lock_dir = Path(tmp) / "lock-symlink" + lock_dir.mkdir() + os.symlink(target, lock_dir / "workflow.lock") + with self.assertRaises(WorkflowError): + with workflow_lock(lock_dir): + self.fail("symlink workflow lock should not be acquired") + + corrupt_dir = Path(tmp) / "corrupt" + corrupt_dir.mkdir() + workflow_events_path(corrupt_dir).write_text("{}\n", encoding="utf-8") + with self.assertRaises(WorkflowError): + record_workflow_binding( + corrupt_dir, workflow_id="wf-contract", node_id="A", run_id="wf-00", + attempt=1, actor="test", reason="bound", snapshot_sha256=digest, + ) + self.assertEqual(workflow_events_path(corrupt_dir).read_text(encoding="utf-8"), "{}\n") + + def test_replay_reconstructs_status_from_snapshot_and_events(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + snapshot = normalize_snapshot(self._snapshot()) + snapshot_path = workflow_snapshot_path(run_dir) + events_path = workflow_events_path(run_dir) + status_path = workflow_status_path(run_dir) + run_path = workflow_run_path(run_dir) + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + digest = snapshot_sha256(snapshot) + + record_workflow_binding( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="A", + run_id="wf-00-run", + attempt=1, + actor="scheduler", + reason="launch A", + snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="A", + actor="scheduler", + reason="A running", + snapshot_sha256=digest, + previous_state="eligible", + next_state="running", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="A", + actor="scheduler", + reason="A complete", + snapshot_sha256=digest, + previous_state="running", + next_state="completed", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="B", + actor="scheduler", + reason="dependency cleared", + snapshot_sha256=digest, + previous_state="blocked", + next_state="eligible", + ) + record_workflow_binding( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="B", + run_id="wf-01-run", + attempt=1, + actor="scheduler", + reason="launch B", + snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="B", + actor="scheduler", + reason="B running", + snapshot_sha256=digest, + previous_state="eligible", + next_state="running", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="B", + actor="scheduler", + reason="B failed", + snapshot_sha256=digest, + previous_state="running", + next_state="failed", + ) + + status = reconstruct_workflow_status(snapshot, events_path) + self.assertEqual(status["workflow_state"], "failed") + self.assertEqual(status["event_count"], 7) + self.assertEqual([item["state"] for item in status["nodes"]], ["completed", "failed"]) + self.assertEqual(status["nodes"][0]["run_id"], "wf-00-run") + self.assertEqual(status["nodes"][1]["run_id"], "wf-01-run") + self.assertEqual(status["nodes"][1]["terminal_reason"], "B failed") + run = build_workflow_run( + snapshot=snapshot, + snapshot_path=snapshot_path, + events_path=events_path, + status_path=status_path, + ) + self.assertEqual(run["status"]["workflow_state"], "failed") + self.assertEqual(run["snapshot_sha256"], digest) + + write_workflow_projection( + snapshot=snapshot, + snapshot_path=snapshot_path, + events_path=events_path, + status_path=status_path, + run_path=run_path, + ) + self.assertTrue(status_path.is_file()) + self.assertTrue(run_path.is_file()) + + def test_replay_allows_failed_node_retry_sequence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + snapshot = normalize_snapshot( + { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": "wf-retry", + "pack_id": "workflow-foundations-next", + "pack_manifest_sha256": "2" * 64, + "nodes": [ + { + "node_id": "A", + "session_id": "wf-00", + "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", + "dependencies": [], + } + ], + } + ) + digest = snapshot_sha256(snapshot) + events_path = workflow_events_path(run_dir) + self._write_events( + events_path, + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "launch A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:01:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 3, + "timestamp": "2026-01-01T00:02:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A failed", + "snapshot_sha256": digest, + "previous_state": "running", + "next_state": "failed", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 4, + "timestamp": "2026-01-01T00:03:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "retry A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run-retry", + "attempt": 2, + "retry_of_run_id": "wf-00-run", + "bound_at": "2026-01-01T00:03:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 5, + "timestamp": "2026-01-01T00:04:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "retry released", + "snapshot_sha256": digest, + "previous_state": "failed", + "next_state": "eligible", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 6, + "timestamp": "2026-01-01T00:05:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "retry running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 7, + "timestamp": "2026-01-01T00:06:00+00:00", + "workflow_id": "wf-retry", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "retry complete", + "snapshot_sha256": digest, + "previous_state": "running", + "next_state": "completed", + "binding": None, + "details": None, + }, + ], + ) + status = reconstruct_workflow_status(snapshot, events_path) + self.assertEqual(status["workflow_state"], "completed") + self.assertEqual(status["nodes"][0]["state"], "completed") + self.assertEqual(status["nodes"][0]["run_id"], "wf-00-run-retry") + self.assertEqual(status["nodes"][0]["attempt"], 2) + self.assertEqual(status["nodes"][0]["retry_of_run_id"], "wf-00-run") + self.assertEqual(status["nodes"][0]["terminal_reason"], "retry complete") + + def test_replay_reports_completed_for_terminal_workflow(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + snapshot = normalize_snapshot( + { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": "wf-done", + "pack_id": "workflow-foundations-next", + "pack_manifest_sha256": "1" * 64, + "nodes": [ + { + "node_id": "A", + "session_id": "wf-00", + "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", + "dependencies": [], + } + ], + } + ) + digest = snapshot_sha256(snapshot) + events_path = workflow_events_path(run_dir) + record_workflow_binding( + run_dir, + workflow_id="wf-done", + node_id="A", + run_id="wf-00-run", + attempt=1, + actor="scheduler", + reason="launch A", + snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, + workflow_id="wf-done", + node_id="A", + actor="scheduler", + reason="running", + snapshot_sha256=digest, + previous_state="eligible", + next_state="running", + ) + record_workflow_transition( + run_dir, + workflow_id="wf-done", + node_id="A", + actor="scheduler", + reason="finished", + snapshot_sha256=digest, + previous_state="running", + next_state="completed", + ) + status = reconstruct_workflow_status(snapshot, events_path) + self.assertEqual(status["workflow_state"], "completed") + self.assertEqual(status["nodes"][0]["state"], "completed") + + def test_replay_rejects_unbound_execution_and_dependency_release(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + snapshot = normalize_snapshot(self._snapshot()) + digest = snapshot_sha256(snapshot) + events_path = workflow_events_path(run_dir) + + self._write_events( + events_path, + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "unbound run", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + } + ], + ) + with self.assertRaisesRegex(WorkflowError, "without a current binding"): + reconstruct_workflow_status(snapshot, events_path) + + self._write_events( + events_path, + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "B", + "actor": "scheduler", + "reason": "too early", + "snapshot_sha256": digest, + "previous_state": "blocked", + "next_state": "eligible", + "binding": None, + "details": None, + } + ], + ) + with self.assertRaisesRegex(WorkflowError, "dependencies complete"): + reconstruct_workflow_status(snapshot, events_path) + + def test_replay_rejects_invalid_binding_lineage(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + snapshot = normalize_snapshot(self._snapshot()) + digest = snapshot_sha256(snapshot) + events_path = workflow_events_path(run_dir) + + cases = [ + ( + "must be current", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "not current", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": False, + }, + "details": None, + } + ], + ), + ( + "first binding must use attempt 1", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "bad attempt", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 2, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + } + ], + ), + ( + "first binding cannot have retry lineage", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "bad retry lineage", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": "wf-previous-run", + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + } + ], + ), + ( + "retry attempt must be the next attempt", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "launch A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:01:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 3, + "timestamp": "2026-01-01T00:02:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A failed", + "snapshot_sha256": digest, + "previous_state": "running", + "next_state": "failed", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 4, + "timestamp": "2026-01-01T00:03:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "skip attempt", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run-retry", + "attempt": 3, + "retry_of_run_id": "wf-00-run", + "bound_at": "2026-01-01T00:03:00+00:00", + "current": True, + }, + "details": None, + }, + ], + ), + ( + "retry must reference the current run", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "launch A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:01:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 3, + "timestamp": "2026-01-01T00:02:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A failed", + "snapshot_sha256": digest, + "previous_state": "running", + "next_state": "failed", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 4, + "timestamp": "2026-01-01T00:03:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "wrong parent", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run-retry", + "attempt": 2, + "retry_of_run_id": "wf-wrong-parent", + "bound_at": "2026-01-01T00:03:00+00:00", + "current": True, + }, + "details": None, + }, + ], + ), + ( + "run ID reused", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "launch A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:01:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 3, + "timestamp": "2026-01-01T00:02:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A failed", + "snapshot_sha256": digest, + "previous_state": "running", + "next_state": "failed", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 4, + "timestamp": "2026-01-01T00:03:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "reused", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 2, + "retry_of_run_id": "wf-00-run", + "bound_at": "2026-01-01T00:03:00+00:00", + "current": True, + }, + "details": None, + }, + ], + ), + ( + "cannot be bound while blocked", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "B", + "actor": "scheduler", + "reason": "too early", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-01-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + } + ], + ), + ( + "cannot be bound while running", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "launch A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:01:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 3, + "timestamp": "2026-01-01T00:02:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "double bind", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run-2", + "attempt": 2, + "retry_of_run_id": "wf-00-run", + "bound_at": "2026-01-01T00:02:00+00:00", + "current": True, + }, + "details": None, + }, + ], + ), + ( + "cannot be bound while completed", + [ + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 1, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "launch A", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run", + "attempt": 1, + "retry_of_run_id": None, + "bound_at": "2026-01-01T00:00:00+00:00", + "current": True, + }, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:01:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A running", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 3, + "timestamp": "2026-01-01T00:02:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "A complete", + "snapshot_sha256": digest, + "previous_state": "running", + "next_state": "completed", + "binding": None, + "details": None, + }, + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 4, + "timestamp": "2026-01-01T00:03:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-bound", + "node_id": "A", + "actor": "scheduler", + "reason": "late bind", + "snapshot_sha256": digest, + "previous_state": None, + "next_state": None, + "binding": { + "run_id": "wf-00-run-2", + "attempt": 2, + "retry_of_run_id": "wf-00-run", + "bound_at": "2026-01-01T00:03:00+00:00", + "current": True, + }, + "details": None, + }, + ], + ), + ] + for expected, events in cases: + with self.subTest(expected=expected): + self._write_events(events_path, events) + with self.assertRaisesRegex(WorkflowError, expected): + reconstruct_workflow_status(snapshot, events_path) + + def test_replay_rejects_corruption_and_invalid_transitions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + snapshot = normalize_snapshot(self._snapshot()) + digest = snapshot_sha256(snapshot) + events_path = workflow_events_path(run_dir) + record_workflow_transition( + run_dir, + workflow_id="wf-contract", + node_id="A", + actor="scheduler", + reason="bad transition", + snapshot_sha256=digest, + previous_state="blocked", + next_state="completed", + ) + with self.assertRaisesRegex(WorkflowError, "invalid workflow transition"): + reconstruct_workflow_status(snapshot, events_path) + + events_path.write_text("not json\n", encoding="utf-8") + with self.assertRaisesRegex(WorkflowError, "invalid workflow event JSON"): + reconstruct_workflow_status(snapshot, events_path) + + events_path.write_text( + json.dumps( + { + "schema": WORKFLOW_EVENT_SCHEMA, + "sequence": 2, + "timestamp": "2026-01-01T00:00:00+00:00", + "workflow_id": "wf-contract", + "kind": "node-transition", + "node_id": "A", + "actor": "scheduler", + "reason": "bad sequence", + "snapshot_sha256": digest, + "previous_state": "eligible", + "next_state": "running", + "binding": None, + "details": None, + } + ) + + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(WorkflowError, "sequence mismatch"): + reconstruct_workflow_status(snapshot, events_path) + + +class WorkflowServiceTests(unittest.TestCase): + def _snapshot(self, *, terminal: bool = False) -> dict[str, object]: + nodes = [ + {"node_id": "A", "session_id": "wf-a", "prompt_path": "a.md", "dependencies": []}, + {"node_id": "B", "session_id": "wf-b", "prompt_path": "b.md", "dependencies": ["A"]}, + ] + snapshot = { + "schema": WORKFLOW_SNAPSHOT_SCHEMA, + "workflow_id": "wf-cli", + "pack_id": "pack", + "pack_manifest_sha256": "1" * 64, + "nodes": [nodes[0]] if terminal else nodes, + } + return normalize_snapshot(snapshot) + + def test_validate_start_status_and_resume_share_result_contract(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + snapshot_path = root / "snapshot.json" + snapshot = self._snapshot() + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + run_dir = root / "workflow" + launched: list[str] = [] + + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(node, run_id): + launched.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = WorkflowService( + scheduler=SchedulerService( + settings=settings, + run_dir=run_dir, + workdir=root, + launch_fn=launch, + ) + ) + + validate = service.validate(snapshot_path) + self.assertEqual(validate["schema"], WORKFLOW_NODE_RESULT_SCHEMA) + self.assertEqual(validate["action"], "validate") + self.assertEqual(validate["result"]["node_count"], 2) + + start = service.start(snapshot_path) + self.assertEqual(start["started"], True) + self.assertEqual(start["scheduled"], ["A"]) + self.assertEqual(launched, ["wf-a"]) + self.assertTrue((run_dir / "workflow-run.json").is_file()) + projected_status = json.loads( + workflow_status_path(run_dir).read_text(encoding="utf-8") + ) + projected_run = json.loads( + workflow_run_path(run_dir).read_text(encoding="utf-8") + ) + self.assertEqual(projected_status["workflow_state"], "running") + self.assertEqual(projected_run["status"], projected_status) + + status = service.status(snapshot_path) + self.assertEqual(status["action"], "status") + self.assertEqual(status["result"]["workflow_state"], "running") + + changed = json.loads(snapshot_path.read_text(encoding="utf-8")) + changed["pack_manifest_sha256"] = "2" * 64 + snapshot_path.write_text(json.dumps(changed), encoding="utf-8") + with self.assertRaisesRegex(WorkflowError, "does not match"): + service.status(snapshot_path) + with self.assertRaisesRegex(WorkflowError, "does not match"): + service.resume(snapshot_path) + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + + resume = service.resume(snapshot_path) + self.assertEqual(resume["resumed"], True) + self.assertEqual(resume["scheduled"], []) + + def test_status_recovers_missing_mutable_projections_from_canonical_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + snapshot = self._snapshot(terminal=True) + snapshot_path = root / "snapshot.json" + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(_node, run_id): + write_run_contracts( + settings.state_root / "runs" / run_id, session_id=run_id + ) + return {"run_id": run_id} + + run_dir = root / "workflow" + service = WorkflowService( + scheduler=SchedulerService( + settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch + ) + ) + service.start(snapshot_path) + workflow_status_path(run_dir).unlink() + workflow_run_path(run_dir).unlink() + + status = service.status(snapshot_path) + + self.assertEqual(status["result"]["workflow_state"], "running") + self.assertTrue(workflow_status_path(run_dir).is_file()) + self.assertTrue(workflow_run_path(run_dir).is_file()) + + def test_started_snapshot_is_read_only_and_writable_tampering_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + snapshot = self._snapshot(terminal=True) + snapshot_path = root / "snapshot.json" + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(node, run_id): + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = WorkflowService( + scheduler=SchedulerService( + settings=settings, run_dir=root / "workflow", workdir=root, launch_fn=launch, + ) + ) + service.start(snapshot_path) + stored = workflow_snapshot_path(root / "workflow") + self.assertEqual(stored.stat().st_mode & 0o222, 0) + stored.chmod(0o644) + with self.assertRaisesRegex(WorkflowError, "read-only"): + service.status(snapshot_path) + + def test_duplicate_start_invalid_root_and_terminal_workflow(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + snapshot_path = root / "snapshot.json" + snapshot = self._snapshot(terminal=True) + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + run_dir = root / "workflow" + launches: list[str] = [] + + settings = replace(defaults(root / "config.toml"), state_root=root / "state") + + def launch(node, run_id): + launches.append(run_id) + write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) + return {"run_id": run_id} + + service = WorkflowService( + scheduler=SchedulerService( + settings=settings, + run_dir=run_dir, + workdir=root, + launch_fn=launch, + ) + ) + service.start(snapshot_path) + digest = snapshot_sha256(snapshot) + record_workflow_transition( + run_dir, + workflow_id="wf-cli", + node_id="A", + actor="test", + reason="terminal complete", + snapshot_sha256=digest, + previous_state="running", + next_state="completed", + ) + with self.assertRaisesRegex(WorkflowError, "already started"): + service.start(snapshot_path) + self.assertEqual(service.resume(snapshot_path)["scheduled"], []) + self.assertEqual(launches, ["wf-a"]) + missing = WorkflowService( + scheduler=SchedulerService( + settings=defaults(root / "config.toml"), + run_dir=root / "missing", + workdir=root, + ) + ) + with self.assertRaisesRegex(WorkflowError, "has not been started"): + missing.resume(snapshot_path) diff --git a/tests/test_workflow_receipt.py b/tests/test_workflow_receipt.py new file mode 100644 index 0000000..0aac990 --- /dev/null +++ b/tests/test_workflow_receipt.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path + +from agent_workflow.config import defaults +from agent_workflow.errors import WorkflowError +from agent_workflow.approval import lifecycle_disposition +from agent_workflow.lifecycle import record +from agent_workflow.receipts import seal_run +from agent_workflow.util import atomic_write_json, sha256_file +from agent_workflow.workflow import ( + normalize_snapshot, + record_workflow_binding, + record_workflow_transition, + snapshot_sha256, + workflow_events_path, + workflow_snapshot_path, +) +from agent_workflow.workflow_receipt import ( + build_workflow_receipt, + seal_workflow, + verify_workflow_receipt, + workflow_receipt_path, +) +from run_fixtures import write_run_contracts + + +def _sealed_child(settings, run_id: str) -> Path: + run = settings.state_root / "runs" / run_id + write_run_contracts(run, session_id=run_id) + completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) + completion.update(result="completed", head_revision="abc123", unresolved=[]) + atomic_write_json(run / "completion.json", completion) + final_status = json.loads((run / "final-status.json").read_text(encoding="utf-8")) + final_status.update(status="completed", disposition=None) + atomic_write_json(run / "final-status.json", final_status) + seal_run(run, session_id=run_id) + status = json.loads((run / "status.json").read_text(encoding="utf-8")) + status.update( + status="completed", + final_receipt_path=str(run / "final-receipt.json"), + final_receipt_sha256=sha256_file(run / "final-receipt.json"), + ) + atomic_write_json(run / "status.json", status) + return run + + +def _terminal_workflow(root: Path): + settings = replace(defaults(), state_root=root / "state") + run_dir = root / "workflow" + run_dir.mkdir() + child = _sealed_child(settings, "child-one") + snapshot = normalize_snapshot( + { + "workflow_id": "receipt-workflow", + "pack_id": "receipt-pack", + "pack_manifest_sha256": "a" * 64, + "nodes": [ + { + "node_id": "build", + "session_id": "child-one", + "prompt_path": "tickets/build.md", + "dependencies": [], + } + ], + } + ) + atomic_write_json(workflow_snapshot_path(run_dir), snapshot) + workflow_snapshot_path(run_dir).chmod(0o444) + workflow_events_path(run_dir).touch() + digest = snapshot_sha256(snapshot) + record_workflow_binding( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="build", + run_id="child-one", + attempt=1, + actor="scheduler", + reason="launch", + snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="build", + actor="scheduler", + reason="running", + snapshot_sha256=digest, + previous_state="eligible", + next_state="running", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="build", + actor="scheduler", + reason="completed", + snapshot_sha256=digest, + previous_state="running", + next_state="completed", + ) + return settings, run_dir, child + + +def _accepted_child(settings, run_id: str) -> tuple[Path, dict[str, object]]: + run = settings.state_root / "runs" / run_id + write_run_contracts(run, session_id=run_id) + completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) + completion.update(result="completed", head_revision="abc123", unresolved=[]) + atomic_write_json(run / "completion.json", completion) + atomic_write_json( + run / "evaluation-runtime.json", + { + "schema": "agent-workflow/evaluation-runtime/v1", + "scorers": ["acceptance_commands"], + }, + ) + final_status = json.loads((run / "final-status.json").read_text(encoding="utf-8")) + final_status.update(status="completed", disposition=None) + atomic_write_json(run / "final-status.json", final_status) + seal_run(run, session_id=run_id) + final_digest = sha256_file(run / "final-receipt.json") + score_receipts = [] + for scorer_id in ("schema_validity", "acceptance_commands"): + score = { + "schema": "agent-workflow/score-receipt/v1", + "scorer": {"id": scorer_id, "version": "1"}, + "final_receipt_sha256": final_digest, + "verdict": "pass", + "facts": {}, + "evidence": [], + } + encoded = json.dumps(score, sort_keys=True, separators=(",", ":")).encode() + digest = hashlib.sha256(encoded).hexdigest() + atomic_write_json(run / "scores" / f"{scorer_id}-{digest}.json", score, mode=0o444) + score_receipts.append(score) + atomic_write_json( + run / "scores" / "score-set.json", + { + "schema": "agent-workflow/score-set/v1", + "final_receipt_sha256": final_digest, + "verdict": "pass", + "scores": score_receipts, + }, + ) + status = json.loads((run / "status.json").read_text(encoding="utf-8")) + status.update( + status="completed", + final_receipt_path=str(run / "final-receipt.json"), + final_receipt_sha256=final_digest, + tier="medium", + ) + atomic_write_json(run / "status.json", status) + record(settings, run_id, action="reviewed", actor="reviewer", reason="review") + record( + settings, + run_id, + action="accepted", + actor="reviewer", + reason="approved", + revision="abc123", + ) + disposition = lifecycle_disposition(run) + assert disposition is not None + return run, disposition + + +def _terminal_approval_workflow(root: Path): + settings = replace(defaults(), state_root=root / "state") + child, disposition = _accepted_child(settings, "approval-child") + run_dir = root / "approval-workflow" + run_dir.mkdir() + snapshot = normalize_snapshot( + { + "workflow_id": "approval-receipt-workflow", + "pack_id": "receipt-pack", + "pack_manifest_sha256": "c" * 64, + "nodes": [ + { + "node_id": "build", + "session_id": "approval-child", + "prompt_path": "build.md", + "dependencies": [], + }, + { + "node_id": "approve", + "kind": "approval", + "approval_for": "build", + "dependencies": ["build"], + }, + ], + } + ) + atomic_write_json(workflow_snapshot_path(run_dir), snapshot) + workflow_snapshot_path(run_dir).chmod(0o444) + workflow_events_path(run_dir).touch() + digest = snapshot_sha256(snapshot) + record_workflow_binding( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="build", + run_id="approval-child", + attempt=1, + actor="scheduler", + reason="launch", + snapshot_sha256=digest, + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="build", + actor="scheduler", + reason="running", + snapshot_sha256=digest, + previous_state="eligible", + next_state="running", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="build", + actor="scheduler", + reason="completed", + snapshot_sha256=digest, + previous_state="running", + next_state="completed", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="approve", + actor="scheduler", + reason="eligible", + snapshot_sha256=digest, + previous_state="blocked", + next_state="eligible", + ) + record_workflow_transition( + run_dir, + workflow_id=snapshot["workflow_id"], + node_id="approve", + actor="scheduler", + reason="canonical lifecycle receipt accepted", + snapshot_sha256=digest, + previous_state="eligible", + next_state="completed", + details={ + "approval_for": "build", + "subject_run_id": "approval-child", + "approval_action": "accepted", + "approval_receipt_sha256": disposition["receipt_sha256"], + "final_receipt_sha256": disposition["final_receipt_sha256"], + "completion_sha256": disposition["completion_sha256"], + "revision": disposition["revision"], + }, + ) + return settings, run_dir, child, disposition + + +class WorkflowReceiptTests(unittest.TestCase): + def test_terminal_workflow_seals_and_verifies_all_child_evidence(self): + with tempfile.TemporaryDirectory() as tmp: + settings, run_dir, child = _terminal_workflow(Path(tmp)) + evidence = seal_workflow(settings=settings, run_dir=run_dir) + receipt = evidence["receipt"] + self.assertTrue(evidence["verified"]) + self.assertEqual(receipt["workflow_state"], "completed") + self.assertEqual(receipt["node_count"], 1) + self.assertEqual(receipt["nodes"][0]["child_run_id"], "child-one") + self.assertEqual( + receipt["nodes"][0]["child_final_receipt_sha256"], + sha256_file(child / "final-receipt.json"), + ) + self.assertEqual(receipt["events_sha256"], sha256_file(workflow_events_path(run_dir))) + self.assertEqual(verify_workflow_receipt(settings=settings, run_dir=run_dir)["receipt"], receipt) + + def test_partial_workflow_cannot_be_sealed(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + settings = replace(defaults(), state_root=root / "state") + run_dir = root / "workflow" + run_dir.mkdir() + snapshot = normalize_snapshot( + { + "workflow_id": "partial-workflow", + "pack_id": "pack", + "pack_manifest_sha256": "b" * 64, + "nodes": [ + { + "node_id": "pending", + "session_id": "pending-run", + "prompt_path": "tickets/pending.md", + "dependencies": [], + } + ], + } + ) + atomic_write_json(workflow_snapshot_path(run_dir), snapshot) + workflow_snapshot_path(run_dir).chmod(0o444) + workflow_events_path(run_dir).touch() + with self.assertRaisesRegex(WorkflowError, "requires terminal nodes"): + build_workflow_receipt(settings=settings, run_dir=run_dir) + + def test_child_receipt_substitution_fails_verification(self): + with tempfile.TemporaryDirectory() as tmp: + settings, run_dir, child = _terminal_workflow(Path(tmp)) + seal_workflow(settings=settings, run_dir=run_dir) + path = child / "final-receipt.json" + os.chmod(path, 0o644) + receipt = json.loads(path.read_text(encoding="utf-8")) + receipt["sealed_at"] = "2099-01-01T00:00:00+00:00" + atomic_write_json(path, receipt) + os.chmod(path, 0o444) + with self.assertRaisesRegex(WorkflowError, "durable evidence"): + verify_workflow_receipt(settings=settings, run_dir=run_dir) + + def test_omitted_or_duplicate_nodes_fail_against_durable_evidence(self): + with tempfile.TemporaryDirectory() as tmp: + settings, run_dir, _ = _terminal_workflow(Path(tmp)) + seal_workflow(settings=settings, run_dir=run_dir) + path = workflow_receipt_path(run_dir) + original = json.loads(path.read_text(encoding="utf-8")) + for mutation in ("omit", "duplicate"): + tampered = json.loads(json.dumps(original)) + if mutation == "omit": + tampered["nodes"] = [] + tampered["node_count"] = 0 + else: + tampered["nodes"].append(dict(tampered["nodes"][0])) + tampered["node_count"] = 2 + os.chmod(path, 0o644) + atomic_write_json(path, tampered) + os.chmod(path, 0o444) + with self.subTest(mutation=mutation): + with self.assertRaises(WorkflowError): + verify_workflow_receipt(settings=settings, run_dir=run_dir) + os.chmod(path, 0o644) + atomic_write_json(path, original) + os.chmod(path, 0o444) + self.assertTrue(verify_workflow_receipt(settings=settings, run_dir=run_dir)["verified"]) + + def test_receipt_itself_must_be_canonical_regular_read_only_file(self): + with tempfile.TemporaryDirectory() as tmp: + settings, run_dir, _ = _terminal_workflow(Path(tmp)) + seal_workflow(settings=settings, run_dir=run_dir) + path = workflow_receipt_path(run_dir) + os.chmod(path, 0o644) + with self.assertRaisesRegex(WorkflowError, "read-only"): + verify_workflow_receipt(settings=settings, run_dir=run_dir) + + def test_approval_evidence_is_reverified_when_workflow_receipt_is_built(self): + with tempfile.TemporaryDirectory() as tmp: + settings, run_dir, _, disposition = _terminal_approval_workflow(Path(tmp)) + sealed = seal_workflow(settings=settings, run_dir=run_dir) + approval = next( + item for item in sealed["receipt"]["nodes"] if item["node_id"] == "approve" + ) + self.assertEqual( + approval["approval_receipt_sha256"], disposition["receipt_sha256"] + ) + receipt_path = Path(disposition["receipt_path"]) + os.chmod(receipt_path, 0o644) + value = json.loads(receipt_path.read_text(encoding="utf-8")) + value["reason"] = "tampered after workflow transition" + atomic_write_json(receipt_path, value) + os.chmod(receipt_path, 0o444) + with self.assertRaisesRegex(WorkflowError, "canonical evidence"): + verify_workflow_receipt(settings=settings, run_dir=run_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_templates.py b/tests/test_workflow_templates.py new file mode 100644 index 0000000..d6320b0 --- /dev/null +++ b/tests/test_workflow_templates.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import unittest + +from agent_workflow.errors import WorkflowError +from agent_workflow.workflow_templates import AUTHORIZED_TEMPLATES, expand_workflow_template + + +def _node(node_id: str) -> dict[str, object]: + return { + "node_id": node_id, + "session_id": f"session-{node_id.lower()}", + "prompt_path": f"tickets/{node_id}.md", + } + + +class WorkflowTemplateTests(unittest.TestCase): + def _expand(self, template: str, parameters): + return expand_workflow_template( + template, + workflow_id=f"wf-{template}", + pack_id="pack", + pack_manifest_sha256="c" * 64, + parameters=parameters, + ) + + def test_pipeline_expansion_is_deterministic_and_canonical(self): + parameters = {"steps": [_node("A"), _node("B"), _node("C")]} + first = self._expand("pipeline", parameters) + second = self._expand("pipeline", parameters) + self.assertEqual(first, second) + by_id = {node["node_id"]: node for node in first["nodes"]} + self.assertEqual(by_id["A"]["dependencies"], []) + self.assertEqual(by_id["B"]["dependencies"], ["A"]) + self.assertEqual(by_id["C"]["dependencies"], ["B"]) + self.assertEqual(parameters["steps"][1].get("dependencies"), None) + + def test_parallel_review_fan_in_has_parallel_reviews_and_sorted_fan_in(self): + graph = self._expand( + "parallel-review-fan-in", + { + "subject": _node("SUBJECT"), + "reviews": [_node("REVIEW_B"), _node("REVIEW_A")], + "fan_in": _node("MERGE"), + }, + ) + by_id = {node["node_id"]: node for node in graph["nodes"]} + self.assertEqual(by_id["REVIEW_A"]["dependencies"], ["SUBJECT"]) + self.assertEqual(by_id["REVIEW_B"]["dependencies"], ["SUBJECT"]) + self.assertEqual(by_id["MERGE"]["dependencies"], ["REVIEW_A", "REVIEW_B"]) + + def test_implementation_independent_review_is_a_two_node_chain(self): + graph = self._expand( + "implementation-independent-review", + {"implementation": _node("IMPLEMENT"), "review": _node("REVIEW")}, + ) + by_id = {node["node_id"]: node for node in graph["nodes"]} + self.assertEqual(by_id["IMPLEMENT"]["dependencies"], []) + self.assertEqual(by_id["REVIEW"]["dependencies"], ["IMPLEMENT"]) + + def test_only_three_authorized_templates_and_invalid_parameters_fail(self): + self.assertEqual( + AUTHORIZED_TEMPLATES, + ( + "pipeline", + "parallel-review-fan-in", + "implementation-independent-review", + ), + ) + cases = ( + ("persona-catalog", {}), + ("pipeline", {"steps": []}), + ("pipeline", {"steps": ["not-a-node"]}), + ("parallel-review-fan-in", {"subject": _node("A"), "reviews": [_node("R")], "fan_in": _node("F")}), + ("implementation-independent-review", {"implementation": _node("A")}), + ) + for template, parameters in cases: + with self.subTest(template=template, parameters=parameters): + with self.assertRaises(WorkflowError): + self._expand(template, parameters) + + def test_template_expansion_rejects_duplicate_or_missing_node_ids(self): + with self.assertRaisesRegex(WorkflowError, "duplicate workflow node ID"): + self._expand("pipeline", {"steps": [_node("A"), _node("A")]}) + broken = _node("A") + broken["node_id"] = "" + with self.assertRaises(WorkflowError): + self._expand("pipeline", {"steps": [broken]}) + + +if __name__ == "__main__": + unittest.main() From b308a9999321c5e7676066c50ac88d66fc1e6eb3 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 08:17:12 -0700 Subject: [PATCH 02/14] fix: isolate noninteractive tmux task lifecycle --- MANIFEST.sha256 | 12 +- README.md | 17 ++- docs/COMMAND_REFERENCE.md | 2 +- skills/agent-workflow-orchestrator/SKILL.md | 20 +++- src/agent_workflow/sessions.py | 121 +++++++++++++++----- 5 files changed, 126 insertions(+), 46 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 0a107a2..eb8bbe6 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -8,7 +8,7 @@ a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUN adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md 893ac876ece3a0617d24e43085292c1f18d5ce9ce50b0aa3b27795039627126e FEATURE_TEST_LEDGER.md 60cab04a3946f6ea21d245cb871a5e86333c85dfd90dc8943ab8adb926e5b0a7 Makefile -975c05a0d5daee26106d2ddbce2b130d2997f1d27bcb66f9b95ebcd102ab2350 README.md +6407c73dbcda6070f42927cf1da588e0038a33d7c39bdca37538b1f457445d97 README.md 76f912155dbfee01d0275a42a0ee636b2aa8f5bd87bb343e98db31082e706d53 REMOVED_PATHS.txt 194ea1d829a11a08aa356befcc72607e37d948e5b16ab9bc0b08788547b22595 SECURITY.md 0aa6f5de99829d5b30d9e15a1661961c9d70c9e81baedd896ffc3bf5d57cd60a SESSION_RESTORE.md @@ -18,7 +18,7 @@ de632c1a29079d61fafa72732c60b34414d92d2aab4004948be39291e363ffbf bin/agent-work a1a9011fe663f0e7f126cb3e1541ead76bebe11cb832a1edd81a8f9aeed3a3a3 config/agent-workflow.example.toml c3f1b77c023226bae4e9e3698475c8cf61fb6c5cc5d161118dbc6a2baacbd612 docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md afb8f6af1237f2fa5d833f0f3636b85169a671e5792207eb9a6e61d9c80d5ee3 docs/ARCHITECTURE.md -3390b6f3a1e3857b7f3516c09b76f615cdbb84b2cc5e3ce4ba9f1054f6ceef7c docs/COMMAND_REFERENCE.md +375b5108dbc46fb23b58d32afaedbd3ac06020b14824df073d919d7bf6eb7512 docs/COMMAND_REFERENCE.md 7bd3d0bf460dd4313298f26f92814b6117b223527221efdc2ca5b63b81eaf5ae docs/DELEGATION_LIFECYCLE.md 840b4553d6d166a9063e3644e5466739749b8312ac5c896b0d2126346eddd29a docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md de70c2fa9c3e57791005ea29e411703d95f8ab27e1d7c8a5227ac2c3b9e33f20 docs/Durable_Orchestration_Delivery_Benchmarks.md @@ -36,7 +36,7 @@ dc186be21e1dd7275df7b88a312b0c2efdb0e6e328319c60c9c9683b836f0b75 docs/MCP_THREA 6d7912023dbc0148dd4735afe6f590fc5a5ab5291b1f9655dc386fe359d1a7f1 docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md 4e9c8d903abc99c531c8fc0f15cb723534dbb373615c351fa43ffcfd43c1816c docs/PHASE_3_BLOCKED_GATE_REPORT.md c18a375de14669c1589849ee794836340ed6bbe16556eaa92fd545e8ce60ae18 docs/PROMPT_PACK_STANDARD.md -45ce780c184cc6ea872ac5ee0c3c2d478d3c29d9611344ebe2bba01b0f657429 docs/PROVIDER_EVIDENCE_RESEARCH.md +40ce83ed1ce1a500e87a31bb386d0e76eaf7d212009af3b8fb5eaa70e7330a83 docs/PROVIDER_EVIDENCE_RESEARCH.md 82f37eadbf373864d7e9f49a63d7143b80f3fa0dfe767d62fe5c81cd70462ad8 docs/SECURITY_TRUST_BOUNDARIES.md eefdc7d02b26c9ffc34ec2e1e948a7533f237deca50275f3fc0debcc82e22b53 docs/STALL_RECOVERY.md a4cf89724dd97f387f755b9469b51f6893738d27daa50ddb11094b1c5525bdd8 docs/TEST_POLICY.md @@ -325,7 +325,7 @@ f3de06fb3b15782ce2f9e9aadfbbfadbef80bce990476b81bd62976fdbbc91fc scripts/releas a5070c546b6621ad93688381e2113a8c0a915d87262238a327637236d0f7f158 scripts/session_status.py 19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh 614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -be9e754cb7a5fe3f0c24dd268c84ce61e412b4f9470ad1512228f1f3ff20931a skills/agent-workflow-orchestrator/SKILL.md +beb6f1f022e68b77043eba1aa83f7e7deb79783c544ba8c30cdfdce17cf2ad3b skills/agent-workflow-orchestrator/SKILL.md 9a8be67f1d197ec814493cda285c8cea12d7a8b02f3b7374fb75087097e39f62 skills/delegated-implementation/SKILL.md 0bf899f4b80c74fa937b92920528bd323bb388baad9758be3a502ad7c45e0fdb skills/phase-gate-review/SKILL.md 6fc060c52b099b8cb9da79826fcc8b2bf69a692feca6eb45fb9a37b175611e08 skills/prompt-pack-builder/SKILL.md @@ -399,13 +399,13 @@ f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_work 0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py 5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py -29bbf530a8adab93c853b5bd9c4db8fff1e6d5b9ddfdec4b8872574ab02c1f14 src/agent_workflow/sessions.py +1c73e650395799b00fbd28b3353e2e9e9d50d3c232a45444be8b4569ff7c4cdd src/agent_workflow/sessions.py 869137b2224fb9d50e5a652b2705f5442a2145cccf6f83c52452aa2045c3acc4 src/agent_workflow/state.py e0e438af91d4b5aaefc0790aca9b87eea01ffa114fe71f4debc64630c534dfd3 src/agent_workflow/tmux.py 063beff32bbbc2aef1c06f3843e00e09c0ac5f6b92663594f734a7386f2c4ff3 src/agent_workflow/util.py a7cda2edc99f9c895d5c9ce05a6de93e8dbc4c96fc7e3604c7057f8ad466e1e7 src/agent_workflow/workflow.py 42e3d67aca21590b450f091fbd6c99630dff881e5dee6825d5f0986e8a5f38e1 src/agent_workflow/workflow_receipt.py -022079a7654488e8bacad05dc46f60f22b628d4f9a50354c9bbc94911bf69da6 src/agent_workflow/workflow_service.py +120c2e1be457da6f693f9a4886b475221062cf644fff20dfe0e287dd1301d3ae src/agent_workflow/workflow_service.py d4c855928723b17787fc82fff3c50b52e05ba3eff0614a33808ecd8196f3112a src/agent_workflow/workflow_templates.py 86784408e230921b3c1f3d8cce8764393077adca184c5508e47b0647ff4043f3 src/agent_workflow/worktrees.py ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c templates/PHASE_GATE_REPORT.md diff --git a/README.md b/README.md index a304851..80b5ee5 100644 --- a/README.md +++ b/README.md @@ -181,12 +181,17 @@ non-interactive and permits only Claude Haiku or `gpt-5.6-luna`; review is also non-interactive; implementation is interactive by default. Additional classes are ordinary `[agent_classes.NAME]` config tables. Interactive agents share the orchestrator window by default. Non-interactive -agents use detached named tmux sessions when `non_interactive_tmux` is -`"dedicated_session"`, so invisible workers do not consume a visible pane. +assignments use an interactive executor in a detached named tmux session when +`non_interactive_tmux` is `"dedicated_session"`, so they can report completion +and close themselves without leaving a blank user-resumable session. They are +not offered as user-resumable agents; the calling agent should inspect their +durable status or delegate a new attempt. Set `non_interactive_tmux` to +`"shared_window"` only when those panes should be visible in the current +window. Claude agents are interactive by default, including exploratory and review -classes. Use the explicit `--no-interactive` (or `--structured`) mode when a -Claude run must be detached/non-interactive. Use `--interactive` to make the -choice explicit for any executor. +classes. `--no-interactive` controls assignment visibility and reuse policy; it +does not create a blank terminal. Use `--structured` when a truly +non-interactive executor stream is required. Use `--model MODEL`; configured defaults apply when it is omitted. Models must be listed for that executor. A `no_go_models` match is rejected unless the run uses `--allow-no-go-model`, which is recorded in provenance. Executor-specific @@ -255,6 +260,8 @@ anything destructive: close enough explicitly idle panes, run the new job as a detached non-interactive task, or cancel. Automation can choose explicitly with `--pane-limit-action close-idle|non-interactive|cancel`; non-TTY callers using the default `prompt` policy fail closed with structured choices. +The pane count and grid apply only to the exact tmux window used for the +launch. Separate tmux windows have independent limits and layouts. The default interactive grid creates two agent columns to the right of the orchestrator before adding vertical splits. It then balances agents across the diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index f800c8c..de8d936 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -39,7 +39,7 @@ agent-workflow kill SESSION agent-workflow restart SESSION [--new-session NAME] ``` -Configured launches enforce class/executor/model allowlists and permission arguments. A no-go model requires `--allow-no-go-model`, which is recorded. `--structured` and native interactive TUI mode are mutually exclusive. Git worktrees must be clean unless `--allow-dirty` is explicit; retries preserve prior evidence and lineage. +Configured launches enforce class/executor/model allowlists and permission arguments. A no-go model requires `--allow-no-go-model`, which is recorded. `--structured` and native interactive TUI mode are mutually exclusive. Git worktrees must be clean unless `--allow-dirty` is explicit; retries preserve prior evidence and lineage. A non-interactive assignment is not user-resumable: by default it runs an interactive executor in a private tmux session, reports through durable handoff records, and closes that session on exit when possible. Pane limits and layout apply only to the exact tmux window containing a visible launch; separate windows have independent capacity. ## Durable messages diff --git a/skills/agent-workflow-orchestrator/SKILL.md b/skills/agent-workflow-orchestrator/SKILL.md index 0e1f6ab..a3f204d 100644 --- a/skills/agent-workflow-orchestrator/SKILL.md +++ b/skills/agent-workflow-orchestrator/SKILL.md @@ -17,8 +17,10 @@ Use this skill when deciding whether work needs a durable delegation or when ope | User-visible child panes are required while the orchestrator is inside tmux | Invoke `agent-workflow launch`; never hand-create the tmux session or pane. | Select an agent class explicitly when the task differs from the configured -default. `exploratory` and `review` are normally non-interactive detached runs; -`implementation` is normally an interactive pane. The application validates +default. `exploratory` and `review` are normally non-interactive assignments; +they use an interactive executor in a private tmux session by default so they +can report completion and close themselves, while `implementation` is normally +an interactive pane. The application validates the class against configured executor/model pairs and rejects no-go models unless the caller supplies the explicit authorization flag. | A running agent needs new guidance | Append `steer`; treat it as pending until a correlated executor acknowledgement exists. | @@ -37,7 +39,14 @@ agent-workflow status project-p0-01 --capture 50 agent-workflow watch project-p0-01 --after 0 --timeout 300 ``` -When launched from a valid current tmux window, `agent-workflow launch` creates a visible pane in that window. Without a usable tmux context, it falls back to a detached named session. Always use the CLI so source baselines, prompts, commands, logs, and receipts are recorded. +When launched from a valid current tmux window, `agent-workflow launch` creates +a visible pane in that window for interactive assignments. Non-interactive +assignments default to a private named tmux session and are not user-resumable; +that private session is a detached named session from the orchestrator's +window; +their launch prompt requires durable completion notification and clean exit. +Always use the CLI so source baselines, prompts, commands, logs, and receipts +are recorded. ## Control and recovery @@ -52,6 +61,11 @@ agent-workflow restart project-p0-01 `steer` persists a durable request. It does not prove that a one-shot executor consumed semantic input. Only a correlated acknowledgement from a supported executor adapter establishes delivery/application. +Do not use `restart` as a user-facing recovery path for a non-interactive +assignment. Inspect its durable status and have the calling agent launch a new +attempt when needed. Pane limits and layout are scoped to the exact tmux +window; another tmux window has its own independent capacity. + ## Review and acceptance ```bash diff --git a/src/agent_workflow/sessions.py b/src/agent_workflow/sessions.py index af29b8c..2e34943 100644 --- a/src/agent_workflow/sessions.py +++ b/src/agent_workflow/sessions.py @@ -111,6 +111,7 @@ def _write_runner( handoff_dir: Path | None = None, stream_format: str = "text", interactive: bool = False, + close_tmux_on_exit: bool = False, ) -> Path: prompt = state_dir / "prompt.md" launch_prompt = state_dir / "launch-prompt.md" @@ -120,23 +121,60 @@ def _write_runner( runner = state_dir / "run.sh" command_text = shlex.join(command) source_root = Path(__file__).resolve().parents[1] - runner.write_text( + runner_invocation = ( + "python3 -m agent_workflow.runner " + f"--run-dir {shlex.quote(str(state_dir))} " + f"--workdir {shlex.quote(str(workdir))} " + f"--stream-format {shlex.quote(stream_format)} " + f"{'--interactive ' if interactive else ''}-- {command_text}" + ) + if interactive and not close_tmux_on_exit: + runner_command = ( + "if [[ -t 0 ]]; then\n" + f" exec {runner_invocation}\n" + "else\n" + f" exec {runner_invocation.replace('--interactive ', '', 1)}\n" + "fi" + ) + elif close_tmux_on_exit: + fallback_invocation = runner_invocation.replace("--interactive ", "", 1) + runner_command = ( + "if [[ -t 0 ]]; then\n" + " set +e\n" + f" {runner_invocation}\n" + " runner_status=$?\n" + " set -e\n" + " if [[ -n \"${AGENT_WORKFLOW_TMUX_SESSION:-}\" ]]; then\n" + " tmux kill-session -t \"$AGENT_WORKFLOW_TMUX_SESSION\" >/dev/null 2>&1 || true\n" + " fi\n" + " exit \"$runner_status\"\n" + "else\n" + f" exec {fallback_invocation}\n" + "fi\n" + ) + else: + runner_command = f"exec {runner_invocation}" + runner_text = ( "#!/usr/bin/env bash\n" "set -Eeuo pipefail\n" f"readonly AGENT_WORKFLOW_SESSION_ID={shlex.quote(session_id)}\n" f"readonly AGENT_WORKFLOW_PROMPT_SOURCE={shlex.quote(str(prompt_source))}\n" f"readonly AGENT_WORKFLOW_HANDOFF_DIR={shlex.quote(str(handoff_dir or ''))}\n" f"readonly AGENT_WORKFLOW_PROMPT_PACK_ROOT={shlex.quote(str(prompt_pack_root or ''))}\n" + ) + if close_tmux_on_exit: + runner_text += ( + f"readonly AGENT_WORKFLOW_TMUX_SESSION={shlex.quote(session_id)}\n" + ) + runner_text += ( "export AGENT_WORKFLOW_SESSION_ID AGENT_WORKFLOW_PROMPT_SOURCE " - "AGENT_WORKFLOW_HANDOFF_DIR AGENT_WORKFLOW_PROMPT_PACK_ROOT\n" - f"export PYTHONPATH={shlex.quote(str(source_root))}${{PYTHONPATH:+:$PYTHONPATH}}\n" - "exec python3 -m agent_workflow.runner " - f"--run-dir {shlex.quote(str(state_dir))} " - f"--workdir {shlex.quote(str(workdir))} " - f"--stream-format {shlex.quote(stream_format)} " - f"{'--interactive ' if interactive else ''}-- {command_text}\n", - encoding="utf-8", + "AGENT_WORKFLOW_HANDOFF_DIR AGENT_WORKFLOW_PROMPT_PACK_ROOT" + + (" AGENT_WORKFLOW_TMUX_SESSION\n" if close_tmux_on_exit else "\n") + + f"export PYTHONPATH={shlex.quote(str(source_root))}${{PYTHONPATH:+:$PYTHONPATH}}\n" + + runner_command + + "\n" ) + runner.write_text(runner_text, encoding="utf-8") runner.chmod(0o755) syntax = subprocess.run( ["bash", "-n", str(runner)], @@ -266,6 +304,7 @@ def _write_launch_prompt( handoff_dir: Path, result_contract: dict[str, Any] | None = None, interactive: bool = False, + detached_interactive: bool = False, ) -> Path: context = [ "# Agent-workflow launch context", @@ -292,6 +331,15 @@ def _write_launch_prompt( "- For a reused assignment, acknowledge its correlated steer message before starting work.", ] ) + elif detached_interactive: + context.extend( + [ + "- This task is non-interactive from the orchestrator's perspective and runs in a private tmux session.", + "- Do not wait for user input or expect a user to resume this run.", + "- On completion, notify the calling agent through the durable completion handoff and a concise progress update.", + "- Exit cleanly when finished; the private tmux session will be closed automatically when possible.", + ] + ) context.extend( [ f"- completion_handoff_dir: `{handoff_dir}`", @@ -530,15 +578,27 @@ def launch( interactive=interactive, allow_active_name=allow_active_agent_name, ) + # `interactive` describes user-visible/reusable assignment semantics. A + # non-interactive assignment still gets a real interactive executor when + # it is placed in tmux, so it can complete and report without leaving a + # blank resumable session behind. + executor_interactive = not structured interactive_parent_target = tmux.current_window_target() if interactive else None - if interactive_parent_target is not None: - pane_count = tmux.interactive_pane_count(interactive_parent_target) + parent_target = ( + interactive_parent_target + if interactive + else tmux.current_window_target() + if settings.non_interactive_tmux == "shared_window" + else None + ) + if parent_target is not None: + pane_count = tmux.interactive_pane_count(parent_target) if pane_count >= settings.max_interactive_agent_panes: raise InteractiveCapacityError( count=pane_count, maximum=settings.max_interactive_agent_panes, idle_sessions=idle_interactive_sessions( - settings, window_target=interactive_parent_target + settings, window_target=parent_target ), ) if tmux.session_exists(session_id): @@ -577,7 +637,7 @@ def launch( ) executor_plan = prepare_executor( settings, executor, explicit_command, structured=structured, - interactive=interactive, + interactive=executor_interactive, model=model, allow_no_go_model=allow_no_go_model, ) @@ -611,6 +671,7 @@ def launch( "executor": executor_plan.name, "stream_format": executor_plan.stream_format, "interactive": interactive, + "executor_interactive": executor_interactive, "model": executor_plan.model, "no_go_authorized": executor_plan.no_go_authorized, "agent_name": agent_name, @@ -634,6 +695,7 @@ def launch( handoff_dir=handoff_dir, result_contract=result_contract, interactive=interactive, + detached_interactive=not interactive and executor_interactive, ) git_info: dict[str, Any] @@ -875,6 +937,7 @@ def launch( "executor": executor_plan.name, "model": executor_plan.model, "interactive": interactive, + "executor_interactive": executor_interactive, "agent_context_path": str(state_dir / "agent-context.json"), "prompt_sha256": sha256_file(prompt_copy), "prompt_pack_root": str(prompt_pack_root) if prompt_pack_root else None, @@ -927,7 +990,8 @@ def launch( prompt_pack_root=prompt_pack_root, handoff_dir=handoff_dir, stream_format=executor_plan.stream_format, - interactive=interactive, + interactive=executor_interactive, + close_tmux_on_exit=(not interactive and executor_interactive and parent_target is None), ) update_status( settings, @@ -937,13 +1001,6 @@ def launch( runner_path=str(runner), ) try: - parent_target = ( - interactive_parent_target - if interactive - else tmux.current_window_target() - if settings.non_interactive_tmux == "shared_window" - else None - ) if parent_target is not None: tmux.configure_server(mouse=settings.mouse) tmux_target = tmux.split_window( @@ -952,15 +1009,9 @@ def launch( str(runner), orchestrator_side=settings.orchestrator_side, pane_name=agent_name, - max_interactive_agent_panes=( - settings.max_interactive_agent_panes if interactive else None - ), - max_interactive_agent_width=( - settings.max_interactive_agent_width if interactive else None - ), - max_interactive_agent_vertical=( - settings.max_interactive_agent_vertical if interactive else None - ), + max_interactive_agent_panes=settings.max_interactive_agent_panes, + max_interactive_agent_width=settings.max_interactive_agent_width, + max_interactive_agent_vertical=settings.max_interactive_agent_vertical, ) tmux_session = tmux_target.split(":", 1)[0] tmux_mode = "shared_window" @@ -1073,7 +1124,10 @@ def observe( else data.get("failure_category") ) safe_actions = [f"agent-workflow status {session_id} --json"] - if observed in {"orphaned", "failed", "interrupted", "killed"}: + if ( + observed in {"orphaned", "failed", "interrupted", "killed"} + and bool(data.get("interactive", False)) + ): safe_actions.append(f"agent-workflow restart {session_id}") elif observed == "possibly_stalled": safe_actions.append(f"agent-workflow interrupt {session_id}") @@ -1319,6 +1373,11 @@ def restart( command_data = json.loads( Path(str(old["command_path"])).read_text(encoding="utf-8") ) + if not bool(command_data.get("interactive", old.get("interactive", False))): + raise WorkflowError( + "non-interactive tasks are not user-resumable; launch a new task " + "or have the calling agent delegate the work again" + ) command = command_data.get("argv") if ( not isinstance(command, list) From 7df6ce3c54d38a834e601ae3432f687ae7831e65 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 09:45:46 -0700 Subject: [PATCH 03/14] feat: apply 0.2.2 acceptance-first delivery --- .github/workflows/ci.yml | 32 + BACKLOG.md | 127 +- CHANGED_FILES.md | 70 - CHANGELOG.md | 13 + CLEANUP_AND_REMOVAL_AUDIT.md | 60 - CONTRIBUTING.md | 32 + FEATURE_TEST_LEDGER.md | 37 - MANIFEST.sha256 | 340 +--- Makefile | 2 +- README.md | 389 +--- REMOVED_PATHS.txt | 11 - SECURITY.md | 51 +- SESSION_RESTORE.md | 54 - SUPPORT.md | 15 + VERSION | 2 +- agent-workflow.yaml | 2 +- docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md | 125 -- docs/ARCHITECTURE.md | 11 +- docs/COMMAND_REFERENCE.md | 4 +- docs/DELEGATION_LIFECYCLE.md | 31 - ...BLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md | 163 -- ...rable_Orchestration_Delivery_Benchmarks.md | 811 -------- docs/EVIDENCE_AND_EVALUATION.md | 49 + docs/GLOBAL_AGENT_ROUTING.md | 36 - docs/MCP_IMPLEMENTATION_CHECKLIST.md | 36 - docs/MCP_SERVER.md | 58 + docs/MCP_SERVER_DECISION.md | 207 -- docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md | 339 ---- docs/MCP_SERVER_IMPLEMENTATION_REPORT.md | 57 - docs/MCP_THREAT_MODEL.md | 84 - docs/MCP_TOOL_AND_RESOURCE_CATALOG.md | 73 - docs/MESSAGING_PRIOR_ART_REPORT.md | 71 - docs/MIGRATING_EXISTING_ASSETS.md | 29 - docs/MODEL_TIERS.md | 22 - docs/OPERATIONS.md | 107 ++ docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md | 202 -- docs/PHASE_3_BLOCKED_GATE_REPORT.md | 20 - docs/PROMPT_PACKS.md | 76 + docs/PROMPT_PACK_STANDARD.md | 53 - docs/PROVIDER_EVIDENCE_RESEARCH.md | 125 -- docs/PUBLIC_RELEASE_READINESS.md | 46 + docs/SECURITY_TRUST_BOUNDARIES.md | 34 - docs/STALL_RECOVERY.md | 29 - docs/TESTING.md | 98 + docs/TEST_POLICY.md | 22 - docs/WORKFLOW_FOUNDATIONS_PLAN.md | 84 - docs/diagrams/REPOSITORY_CHART_PACK.md | 40 +- docs/diagrams/public-release-path.mmd | 10 + docs/diagrams/testing-strategy.mmd | 14 + .../BKL-003-RESEARCH-TICKET-COMPLETION.md | 62 - .../BKL-003-TICKET-COMPLETION.md | 65 - .../FINAL_CRITICAL_REVIEW.md | 67 - .../P0-00-TICKET_COMPLETION.md | 12 - .../P0-01-TICKET_COMPLETION.md | 9 - .../P1-00-TICKET_COMPLETION.md | 7 - docs/execution-evidence/PHASE_GATE_REPORT.md | 23 - .../WF-10-TICKET-COMPLETION.md | 61 - .../WF-11-TICKET-COMPLETION.md | 61 - .../WF-12-TICKET-COMPLETION.md | 61 - .../WF-20-TICKET-COMPLETION.md | 62 - .../WF-21-TICKET-COMPLETION.md | 62 - .../WF-22-TICKET-COMPLETION.md | 63 - .../WORKFLOW_BENCHMARK_PHASE_GATE.md | 56 - docs/man/agent-workflow-mcp.1 | 4 +- docs/man/agent-workflow-workflow.1 | 4 +- docs/man/agent-workflow.1 | 2 +- install.sh | 1 + .../DELEGATION_RUNBOOK.md | 62 - .../EXECUTION_PROTOCOL.md | 78 - .../MANIFEST.sha256 | 26 - .../agent-workflow-skills-and-mcp/README.md | 44 - .../agent-workflow-skills-and-mcp/pack.yaml | 13 - .../phase-0/MASTER_IMPLEMENTATION_PROMPT.md | 36 - .../phase-0/README.md | 37 - .../phase-0/task-manifest.yaml | 12 - .../tickets/P0-00-baseline-and-preflight.md | 52 - .../tickets/P0-01-skill-cli-integration.md | 54 - .../phase-1/MASTER_IMPLEMENTATION_PROMPT.md | 36 - .../phase-1/README.md | 33 - .../phase-1/task-manifest.yaml | 8 - .../tickets/P1-00-mcp-server-decision.md | 80 - .../references/README.md | 16 - .../references/code-structure-outlines.md | 14 - .../scripts/archive-prompt-pack.sh | 4 - .../scripts/check-delegation.sh | 4 - .../scripts/create-ticket-worktree.sh | 6 - .../scripts/foreground-delegation.sh | 4 - .../scripts/launch-delegation.sh | 4 - .../scripts/restart-delegation.sh | 6 - .../scripts/stop-delegation.sh | 4 - .../scripts/validate-prompt-pack.sh | 4 - .../templates/PHASE_GATE_REPORT.md | 32 - .../templates/TICKET_COMPLETION.md | 58 - .../templates/source-baseline.example.json | 12 - .../CHATGPT_HANDOFF_PROMPT.md | 78 - .../DELEGATION_RUNBOOK.md | 67 - .../EXECUTION_PROTOCOL.md | 73 - .../MANIFEST.sha256 | 41 - .../README.md | 46 - .../workflow-node-result.schema.json | 1 - .../pack.yaml | 9 - .../phase-0/MASTER_IMPLEMENTATION_PROMPT.md | 1 - .../phase-0/README.md | 3 - .../phase-0/task-manifest.yaml | 27 - .../tickets/WF-00-contract-and-state.md | 8 - .../tickets/WF-01-scheduler-service.md | 8 - .../phase-0/tickets/WF-02-restart-and-cli.md | 8 - .../phase-1/MASTER_IMPLEMENTATION_PROMPT.md | 1 - .../phase-1/README.md | 1 - .../phase-1/task-manifest.yaml | 19 - .../phase-1/tickets/WF-10-approval-gates.md | 8 - .../phase-1/tickets/WF-11-result-binding.md | 8 - .../phase-1/tickets/WF-12-workflow-receipt.md | 8 - .../phase-2/MASTER_IMPLEMENTATION_PROMPT.md | 1 - .../phase-2/README.md | 1 - .../phase-2/task-manifest.yaml | 19 - .../phase-2/tickets/WF-20-templates.md | 8 - .../phase-2/tickets/WF-21-routing-advice.md | 8 - .../tickets/WF-22-integration-review.md | 8 - .../phase-3/MASTER_IMPLEMENTATION_PROMPT.md | 8 - .../phase-3/README.md | 6 - .../phase-3/task-manifest.yaml | 14 - .../phase-3/tickets/BKL-003-RESEARCH.md | 30 - .../phase-3/tickets/BKL-003.md | 30 - .../references/README.md | 9 - .../scripts/archive-prompt-pack.sh | 4 - .../scripts/check-delegation.sh | 4 - .../scripts/create-ticket-worktree.sh | 6 - .../scripts/foreground-delegation.sh | 4 - .../scripts/launch-delegation.sh | 4 - .../scripts/restart-delegation.sh | 6 - .../scripts/stop-delegation.sh | 4 - .../scripts/validate-prompt-pack.sh | 4 - .../templates/PHASE_GATE_REPORT.md | 32 - .../templates/TICKET_COMPLETION.md | 58 - .../templates/source-baseline.example.json | 12 - .../mcp-server-next/CHATGPT_HANDOFF_PROMPT.md | 38 +- prompt-packs/mcp-server-next/MANIFEST.sha256 | 40 +- prompt-packs/mcp-server-next/README.md | 76 +- prompt-packs/mcp-server-next/pack.yaml | 2 +- .../phase-0/MASTER_IMPLEMENTATION_PROMPT.md | 33 +- .../mcp-server-next/phase-0/README.md | 31 +- .../phase-0/task-manifest.yaml | 28 +- .../tickets/MCP3-00-workflow-baseline.md} | 15 +- .../tickets/MCP3-01-workflow-aware-tools.md} | 6 +- .../tickets/MCP3-02-independent-review.md} | 10 +- .../tickets/P0-00-baseline-and-preflight.md | 53 - .../tickets/P0-01-primary-research-refresh.md | 37 - .../P0-02-architecture-review-and-plan.md | 27 - .../phase-1/MASTER_IMPLEMENTATION_PROMPT.md | 32 - .../mcp-server-next/phase-1/README.md | 25 - .../phase-1/task-manifest.yaml | 19 - .../tickets/P1-00-baseline-and-preflight.md | 56 - .../P1-01-domain-services-and-contracts.md | 32 - .../tickets/P1-02-independent-phase-review.md | 18 - .../phase-2/MASTER_IMPLEMENTATION_PROMPT.md | 32 - .../mcp-server-next/phase-2/README.md | 26 - .../phase-2/task-manifest.yaml | 19 - .../tickets/P2-00-baseline-and-preflight.md | 56 - .../tickets/P2-01-read-only-stdio-server.md | 28 - .../P2-02-security-conformance-review.md | 20 - .../phase-3/MASTER_IMPLEMENTATION_PROMPT.md | 15 - .../mcp-server-next/phase-3/README.md | 26 - .../phase-3/task-manifest.yaml | 19 - .../mcp-server-next/references/README.md | 23 +- .../references/code-structure-outlines.md | 2 +- .../DELEGATION_RUNBOOK.md | 61 - .../EXECUTION_PROTOCOL.md | 73 - .../MANIFEST.sha256 | 33 - .../orchestrator-messaging-evals/README.md | 40 - .../orchestrator-messaging-evals/pack.yaml | 13 - .../phase-0/MASTER_IMPLEMENTATION_PROMPT.md | 31 - .../phase-0/README.md | 27 - .../phase-0/task-manifest.yaml | 8 - .../tickets/P0-00-baseline-and-preflight.md | 31 - .../phase-1/MASTER_IMPLEMENTATION_PROMPT.md | 31 - .../phase-1/README.md | 27 - .../phase-1/task-manifest.yaml | 8 - .../tickets/P1-00-baseline-and-preflight.md | 37 - .../phase-2/MASTER_IMPLEMENTATION_PROMPT.md | 31 - .../phase-2/README.md | 27 - .../phase-2/task-manifest.yaml | 8 - .../tickets/P2-00-baseline-and-preflight.md | 37 - .../phase-3/MASTER_IMPLEMENTATION_PROMPT.md | 31 - .../phase-3/README.md | 27 - .../phase-3/task-manifest.yaml | 8 - .../tickets/P3-00-baseline-and-preflight.md | 39 - .../references/README.md | 3 - .../references/code-structure-outlines.md | 14 - .../scripts/archive-prompt-pack.sh | 4 - .../scripts/check-delegation.sh | 4 - .../scripts/create-ticket-worktree.sh | 6 - .../scripts/foreground-delegation.sh | 4 - .../scripts/launch-delegation.sh | 4 - .../scripts/restart-delegation.sh | 6 - .../scripts/stop-delegation.sh | 4 - .../scripts/validate-prompt-pack.sh | 4 - .../templates/PHASE_GATE_REPORT.md | 32 - .../templates/TICKET_COMPLETION.md | 58 - .../templates/source-baseline.example.json | 12 - .../CHATGPT_HANDOFF_PROMPT.md | 1 - .../DELEGATION_RUNBOOK.md | 61 - .../EXECUTION_PROTOCOL.md | 73 - .../workflow-foundations-next/MANIFEST.sha256 | 36 - .../workflow-foundations-next/README.md | 14 - .../workflow-node-result.schema.json | 1 - .../workflow-foundations-next/pack.yaml | 8 - .../phase-0/MASTER_IMPLEMENTATION_PROMPT.md | 1 - .../phase-0/README.md | 3 - .../phase-0/task-manifest.yaml | 27 - .../tickets/WF-00-contract-and-state.md | 8 - .../tickets/WF-01-scheduler-service.md | 8 - .../phase-0/tickets/WF-02-restart-and-cli.md | 8 - .../phase-1/MASTER_IMPLEMENTATION_PROMPT.md | 1 - .../phase-1/README.md | 1 - .../phase-1/task-manifest.yaml | 19 - .../phase-1/tickets/WF-10-approval-gates.md | 8 - .../phase-1/tickets/WF-11-result-binding.md | 8 - .../phase-1/tickets/WF-12-workflow-receipt.md | 8 - .../phase-2/MASTER_IMPLEMENTATION_PROMPT.md | 1 - .../phase-2/README.md | 1 - .../phase-2/task-manifest.yaml | 19 - .../phase-2/tickets/WF-20-templates.md | 8 - .../phase-2/tickets/WF-21-routing-advice.md | 8 - .../tickets/WF-22-integration-review.md | 8 - .../references/README.md | 9 - .../scripts/archive-prompt-pack.sh | 4 - .../scripts/check-delegation.sh | 4 - .../scripts/create-ticket-worktree.sh | 6 - .../scripts/foreground-delegation.sh | 4 - .../scripts/launch-delegation.sh | 4 - .../scripts/restart-delegation.sh | 6 - .../scripts/stop-delegation.sh | 4 - .../scripts/validate-prompt-pack.sh | 4 - .../templates/PHASE_GATE_REPORT.md | 32 - .../templates/TICKET_COMPLETION.md | 58 - .../templates/source-baseline.example.json | 12 - pyproject.toml | 11 +- schemas/evaluation-runtime.schema.json | 92 + scripts/audit-release-assets.py | 4 + scripts/release-check.sh | 2 +- skills/agent-workflow-orchestrator/SKILL.md | 25 +- src/agent_workflow/__init__.py | 2 +- src/agent_workflow/cli.py | 2 +- src/agent_workflow/doctor.py | 2 +- tests/__init__.py | 1 + tests/acceptance/test_cli_product_journeys.py | 142 ++ tests/acceptance/test_delegation_journeys.py | 264 +++ tests/acceptance/test_evaluation_journeys.py | 119 ++ tests/acceptance/test_workflow_journeys.py | 280 +++ tests/conftest.py | 331 ++++ tests/fixtures/native-jobs/valid-job.json | 15 - .../deterministic-json/app/__init__.py | 0 .../deterministic-json/app/normalize.py | 23 - .../deterministic-json/evaluation.json | 39 - .../mutations/first_wins.py | 6 - .../mutations/mutates_input.py | 4 - .../deterministic-json/mutations/no_dedup.py | 2 - .../tests/test_normalize.py | 21 - .../priority-picker/README.md | 22 - tests/future/README.md | 9 + tests/future/test_late_steering_journey.py | 58 + tests/invariants/test_durable_state.py | 64 + .../test_provider_and_evaluation.py | 62 + tests/invariants/test_security_boundaries.py | 79 + tests/live/test_live_adapters.py | 122 ++ tests/release/test_distribution.py | 63 + tests/{run_fixtures.py => support.py} | 77 +- tests/test_agent_context.py | 142 -- tests/test_approval.py | 414 ---- tests/test_bindings.py | 179 -- tests/test_cli_parsing.py | 192 -- tests/test_config.py | 91 - tests/test_contracts.py | 82 - tests/test_diagnostics.py | 20 - tests/test_doctor.py | 35 - tests/test_eval_commands.py | 50 - tests/test_eval_compare.py | 52 - tests/test_eval_oracles.py | 29 - tests/test_eval_reporting.py | 84 - tests/test_eval_runtime.py | 83 - tests/test_eval_scope.py | 74 - tests/test_eval_scoring.py | 84 - tests/test_eval_trials.py | 97 - tests/test_evaluation.py | 63 - tests/test_executor_events.py | 94 - tests/test_inspect_adapter.py | 38 - tests/test_install_uninstall.py | 86 - tests/test_ledger.py | 38 - tests/test_lifecycle.py | 247 --- tests/test_manifest_validation.py | 177 -- tests/test_mcp_server.py | 53 - tests/test_mcp_services.py | 108 -- tests/test_messages.py | 264 --- tests/test_metrics.py | 109 -- tests/test_native_jobs.py | 78 - tests/test_optional_integrations.py | 25 - tests/test_provider_evidence.py | 157 -- tests/test_public_eval_fixtures.py | 36 - tests/test_receipts.py | 103 - tests/test_regression_eval_fixture.py | 56 - tests/test_release_assets.py | 65 - tests/test_routing.py | 93 - tests/test_runner_generation.py | 408 ---- tests/test_scaffold.py | 22 - tests/test_session_launch.py | 713 ------- tests/test_skill_contracts.py | 49 - tests/test_tmux.py | 194 -- tests/test_util.py | 20 - tests/test_workflow.py | 1702 ----------------- tests/test_workflow_receipt.py | 378 ---- tests/test_workflow_templates.py | 92 - tests/test_worktree.py | 84 - 313 files changed, 2677 insertions(+), 15158 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 CHANGED_FILES.md delete mode 100644 CLEANUP_AND_REMOVAL_AUDIT.md create mode 100644 CONTRIBUTING.md delete mode 100644 FEATURE_TEST_LEDGER.md delete mode 100644 REMOVED_PATHS.txt delete mode 100644 SESSION_RESTORE.md create mode 100644 SUPPORT.md delete mode 100644 docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md delete mode 100644 docs/DELEGATION_LIFECYCLE.md delete mode 100644 docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md delete mode 100644 docs/Durable_Orchestration_Delivery_Benchmarks.md create mode 100644 docs/EVIDENCE_AND_EVALUATION.md delete mode 100644 docs/GLOBAL_AGENT_ROUTING.md delete mode 100644 docs/MCP_IMPLEMENTATION_CHECKLIST.md create mode 100644 docs/MCP_SERVER.md delete mode 100644 docs/MCP_SERVER_DECISION.md delete mode 100644 docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md delete mode 100644 docs/MCP_SERVER_IMPLEMENTATION_REPORT.md delete mode 100644 docs/MCP_THREAT_MODEL.md delete mode 100644 docs/MCP_TOOL_AND_RESOURCE_CATALOG.md delete mode 100644 docs/MESSAGING_PRIOR_ART_REPORT.md delete mode 100644 docs/MIGRATING_EXISTING_ASSETS.md delete mode 100644 docs/MODEL_TIERS.md create mode 100644 docs/OPERATIONS.md delete mode 100644 docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md delete mode 100644 docs/PHASE_3_BLOCKED_GATE_REPORT.md create mode 100644 docs/PROMPT_PACKS.md delete mode 100644 docs/PROMPT_PACK_STANDARD.md delete mode 100644 docs/PROVIDER_EVIDENCE_RESEARCH.md create mode 100644 docs/PUBLIC_RELEASE_READINESS.md delete mode 100644 docs/SECURITY_TRUST_BOUNDARIES.md delete mode 100644 docs/STALL_RECOVERY.md create mode 100644 docs/TESTING.md delete mode 100644 docs/TEST_POLICY.md delete mode 100644 docs/WORKFLOW_FOUNDATIONS_PLAN.md create mode 100644 docs/diagrams/public-release-path.mmd create mode 100644 docs/diagrams/testing-strategy.mmd delete mode 100644 docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/BKL-003-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/FINAL_CRITICAL_REVIEW.md delete mode 100644 docs/execution-evidence/P0-00-TICKET_COMPLETION.md delete mode 100644 docs/execution-evidence/P0-01-TICKET_COMPLETION.md delete mode 100644 docs/execution-evidence/P1-00-TICKET_COMPLETION.md delete mode 100644 docs/execution-evidence/PHASE_GATE_REPORT.md delete mode 100644 docs/execution-evidence/WF-10-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/WF-11-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/WF-12-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/WF-20-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/WF-21-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/WF-22-TICKET-COMPLETION.md delete mode 100644 docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/DELEGATION_RUNBOOK.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/EXECUTION_PROTOCOL.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/MANIFEST.sha256 delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/README.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/pack.yaml delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-0/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-0/README.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-0/task-manifest.yaml delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-00-baseline-and-preflight.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-01-skill-cli-integration.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-1/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-1/README.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-1/task-manifest.yaml delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/phase-1/tickets/P1-00-mcp-server-decision.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/references/README.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/references/code-structure-outlines.md delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/archive-prompt-pack.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/check-delegation.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/create-ticket-worktree.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/foreground-delegation.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/launch-delegation.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/restart-delegation.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/stop-delegation.sh delete mode 100755 prompt-packs/agent-workflow-skills-and-mcp/scripts/validate-prompt-pack.sh delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/templates/PHASE_GATE_REPORT.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/templates/TICKET_COMPLETION.md delete mode 100644 prompt-packs/agent-workflow-skills-and-mcp/templates/source-baseline.example.json delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/CHATGPT_HANDOFF_PROMPT.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/DELEGATION_RUNBOOK.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/EXECUTION_PROTOCOL.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/README.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/contracts/workflow-node-result.schema.json delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/pack.yaml delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-0/README.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-0/task-manifest.yaml delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-00-contract-and-state.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-01-scheduler-service.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-02-restart-and-cli.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-1/README.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-1/task-manifest.yaml delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-10-approval-gates.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-11-result-binding.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-12-workflow-receipt.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-2/README.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-2/task-manifest.yaml delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-20-templates.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-21-routing-advice.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-22-integration-review.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-3/README.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-3/task-manifest.yaml delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003-RESEARCH.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/references/README.md delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/archive-prompt-pack.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/check-delegation.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/create-ticket-worktree.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/foreground-delegation.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/launch-delegation.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/restart-delegation.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/stop-delegation.sh delete mode 100755 prompt-packs/chatgpt-workflow-completion-next/scripts/validate-prompt-pack.sh delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/templates/PHASE_GATE_REPORT.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/templates/TICKET_COMPLETION.md delete mode 100644 prompt-packs/chatgpt-workflow-completion-next/templates/source-baseline.example.json rename prompt-packs/mcp-server-next/{phase-3/tickets/P3-00-workflow-baseline.md => phase-0/tickets/MCP3-00-workflow-baseline.md} (71%) rename prompt-packs/mcp-server-next/{phase-3/tickets/P3-01-workflow-aware-tools.md => phase-0/tickets/MCP3-01-workflow-aware-tools.md} (90%) rename prompt-packs/mcp-server-next/{phase-3/tickets/P3-02-independent-review.md => phase-0/tickets/MCP3-02-independent-review.md} (75%) delete mode 100644 prompt-packs/mcp-server-next/phase-0/tickets/P0-00-baseline-and-preflight.md delete mode 100644 prompt-packs/mcp-server-next/phase-0/tickets/P0-01-primary-research-refresh.md delete mode 100644 prompt-packs/mcp-server-next/phase-0/tickets/P0-02-architecture-review-and-plan.md delete mode 100644 prompt-packs/mcp-server-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/mcp-server-next/phase-1/README.md delete mode 100644 prompt-packs/mcp-server-next/phase-1/task-manifest.yaml delete mode 100644 prompt-packs/mcp-server-next/phase-1/tickets/P1-00-baseline-and-preflight.md delete mode 100644 prompt-packs/mcp-server-next/phase-1/tickets/P1-01-domain-services-and-contracts.md delete mode 100644 prompt-packs/mcp-server-next/phase-1/tickets/P1-02-independent-phase-review.md delete mode 100644 prompt-packs/mcp-server-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/mcp-server-next/phase-2/README.md delete mode 100644 prompt-packs/mcp-server-next/phase-2/task-manifest.yaml delete mode 100644 prompt-packs/mcp-server-next/phase-2/tickets/P2-00-baseline-and-preflight.md delete mode 100644 prompt-packs/mcp-server-next/phase-2/tickets/P2-01-read-only-stdio-server.md delete mode 100644 prompt-packs/mcp-server-next/phase-2/tickets/P2-02-security-conformance-review.md delete mode 100644 prompt-packs/mcp-server-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/mcp-server-next/phase-3/README.md delete mode 100644 prompt-packs/mcp-server-next/phase-3/task-manifest.yaml delete mode 100644 prompt-packs/orchestrator-messaging-evals/DELEGATION_RUNBOOK.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/EXECUTION_PROTOCOL.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/MANIFEST.sha256 delete mode 100644 prompt-packs/orchestrator-messaging-evals/README.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/pack.yaml delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-0/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-0/README.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-0/task-manifest.yaml delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-0/tickets/P0-00-baseline-and-preflight.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-1/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-1/README.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-1/task-manifest.yaml delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-1/tickets/P1-00-baseline-and-preflight.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-2/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-2/README.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-2/task-manifest.yaml delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-2/tickets/P2-00-baseline-and-preflight.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-3/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-3/README.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-3/task-manifest.yaml delete mode 100644 prompt-packs/orchestrator-messaging-evals/phase-3/tickets/P3-00-baseline-and-preflight.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/references/README.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/references/code-structure-outlines.md delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/archive-prompt-pack.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/check-delegation.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/create-ticket-worktree.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/foreground-delegation.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/launch-delegation.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/restart-delegation.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/stop-delegation.sh delete mode 100755 prompt-packs/orchestrator-messaging-evals/scripts/validate-prompt-pack.sh delete mode 100644 prompt-packs/orchestrator-messaging-evals/templates/PHASE_GATE_REPORT.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/templates/TICKET_COMPLETION.md delete mode 100644 prompt-packs/orchestrator-messaging-evals/templates/source-baseline.example.json delete mode 100644 prompt-packs/workflow-foundations-next/CHATGPT_HANDOFF_PROMPT.md delete mode 100644 prompt-packs/workflow-foundations-next/DELEGATION_RUNBOOK.md delete mode 100644 prompt-packs/workflow-foundations-next/EXECUTION_PROTOCOL.md delete mode 100644 prompt-packs/workflow-foundations-next/MANIFEST.sha256 delete mode 100644 prompt-packs/workflow-foundations-next/README.md delete mode 100644 prompt-packs/workflow-foundations-next/contracts/workflow-node-result.schema.json delete mode 100644 prompt-packs/workflow-foundations-next/pack.yaml delete mode 100644 prompt-packs/workflow-foundations-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-0/README.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-0/task-manifest.yaml delete mode 100644 prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-1/README.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-1/task-manifest.yaml delete mode 100644 prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-2/README.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-2/task-manifest.yaml delete mode 100644 prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md delete mode 100644 prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md delete mode 100644 prompt-packs/workflow-foundations-next/references/README.md delete mode 100755 prompt-packs/workflow-foundations-next/scripts/archive-prompt-pack.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/check-delegation.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/create-ticket-worktree.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/foreground-delegation.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/launch-delegation.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/restart-delegation.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/stop-delegation.sh delete mode 100755 prompt-packs/workflow-foundations-next/scripts/validate-prompt-pack.sh delete mode 100644 prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md delete mode 100644 prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md delete mode 100644 prompt-packs/workflow-foundations-next/templates/source-baseline.example.json create mode 100644 schemas/evaluation-runtime.schema.json create mode 100644 tests/__init__.py create mode 100644 tests/acceptance/test_cli_product_journeys.py create mode 100644 tests/acceptance/test_delegation_journeys.py create mode 100644 tests/acceptance/test_evaluation_journeys.py create mode 100644 tests/acceptance/test_workflow_journeys.py create mode 100644 tests/conftest.py delete mode 100644 tests/fixtures/native-jobs/valid-job.json delete mode 100644 tests/fixtures/regression-evals/deterministic-json/app/__init__.py delete mode 100644 tests/fixtures/regression-evals/deterministic-json/app/normalize.py delete mode 100644 tests/fixtures/regression-evals/deterministic-json/evaluation.json delete mode 100644 tests/fixtures/regression-evals/deterministic-json/mutations/first_wins.py delete mode 100644 tests/fixtures/regression-evals/deterministic-json/mutations/mutates_input.py delete mode 100644 tests/fixtures/regression-evals/deterministic-json/mutations/no_dedup.py delete mode 100644 tests/fixtures/regression-evals/deterministic-json/tests/test_normalize.py delete mode 100644 tests/fixtures/regression-evals/priority-picker/README.md create mode 100644 tests/future/README.md create mode 100644 tests/future/test_late_steering_journey.py create mode 100644 tests/invariants/test_durable_state.py create mode 100644 tests/invariants/test_provider_and_evaluation.py create mode 100644 tests/invariants/test_security_boundaries.py create mode 100644 tests/live/test_live_adapters.py create mode 100644 tests/release/test_distribution.py rename tests/{run_fixtures.py => support.py} (55%) delete mode 100644 tests/test_agent_context.py delete mode 100644 tests/test_approval.py delete mode 100644 tests/test_bindings.py delete mode 100644 tests/test_cli_parsing.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_contracts.py delete mode 100644 tests/test_diagnostics.py delete mode 100644 tests/test_doctor.py delete mode 100644 tests/test_eval_commands.py delete mode 100644 tests/test_eval_compare.py delete mode 100644 tests/test_eval_oracles.py delete mode 100644 tests/test_eval_reporting.py delete mode 100644 tests/test_eval_runtime.py delete mode 100644 tests/test_eval_scope.py delete mode 100644 tests/test_eval_scoring.py delete mode 100644 tests/test_eval_trials.py delete mode 100644 tests/test_evaluation.py delete mode 100644 tests/test_executor_events.py delete mode 100644 tests/test_inspect_adapter.py delete mode 100644 tests/test_install_uninstall.py delete mode 100644 tests/test_ledger.py delete mode 100644 tests/test_lifecycle.py delete mode 100644 tests/test_manifest_validation.py delete mode 100644 tests/test_mcp_server.py delete mode 100644 tests/test_mcp_services.py delete mode 100644 tests/test_messages.py delete mode 100644 tests/test_metrics.py delete mode 100644 tests/test_native_jobs.py delete mode 100644 tests/test_optional_integrations.py delete mode 100644 tests/test_provider_evidence.py delete mode 100644 tests/test_public_eval_fixtures.py delete mode 100644 tests/test_receipts.py delete mode 100644 tests/test_regression_eval_fixture.py delete mode 100644 tests/test_release_assets.py delete mode 100644 tests/test_routing.py delete mode 100644 tests/test_runner_generation.py delete mode 100644 tests/test_scaffold.py delete mode 100644 tests/test_session_launch.py delete mode 100644 tests/test_skill_contracts.py delete mode 100644 tests/test_tmux.py delete mode 100644 tests/test_util.py delete mode 100644 tests/test_workflow.py delete mode 100644 tests/test_workflow_receipt.py delete mode 100644 tests/test_workflow_templates.py delete mode 100644 tests/test_worktree.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5d86b1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install system tools + run: sudo apt-get update && sudo apt-get install -y tmux zstd + - name: Install project + run: python -m pip install -e '.[dev]' + - name: Acceptance and invariant suite + run: pytest + - name: Compile and release assets + run: | + python -m compileall -q src + bash -n install.sh uninstall.sh scripts/*.sh + python scripts/audit-release-assets.py diff --git a/BACKLOG.md b/BACKLOG.md index b53f45f..8cbde52 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1,109 +1,58 @@ -# agent-workflow Backlog +# agent-workflow backlog -This is the authoritative register for unfinished repository work. Create, -prioritize, close, or defer tasks here; detailed design, acceptance criteria, -and prior-art evidence live in the linked reference documents. Historical -plans and implementation reports are not parallel task trackers. +This is the only task register for unfinished work. Design documents explain architecture and constraints; they do not maintain parallel checklists. Completed implementation detail belongs in Git history and [CHANGELOG.md](CHANGELOG.md). -## Operating rules +## Rules -- Every active task has a stable ID, state, priority, owner type, and exit - evidence. New work starts here before it is delegated. -- A task is `done` only after its stated evidence exists; move it to the - completed history rather than leaving completed checkboxes in design docs. -- `blocked` tasks name the missing external input. They are not implementation - authorization until that input is supplied. -- `decision` items require an explicit maintainer choice; do not turn them - into infrastructure by default. -- Link here from a deep design document instead of copying its instructions. +- Every active item has a stable ID, priority, state, and observable exit evidence. +- `done` means the behavior and evidence exist; completed items move to the history summary. +- `blocked` names the missing external prerequisite. +- `decision` requires explicit maintainer authorization before implementation. +- New features require an installed-product acceptance journey or an approved strict future specification. ## Now | ID | Priority | State | Work and exit evidence | Reference | |---|---|---|---|---| -| BKL-001 | P0 | ready | Add durable per-consumer control-log cursors and idempotent handling/disposition. Prove restart recovery, duplicate delivery safety, and cursor advancement only after handling succeeds. | [research: Stage A and Priority 2](docs/Durable_Orchestration_Delivery_Benchmarks.md#stage-a--single-host-tmux) | -| BKL-006 | P0 | done | Make `agent-workflow` operationally discoverable to agents: add an orchestration skill, connect existing skills to CLI/runbooks/protocols, define native-agent versus durable-run boundaries, and install/test supported discovery roots. | [P0 task breakdown](docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md) | -| BKL-002 | P0 | ready | Research, define, and implement executor-specific late steering for detached/non-interactive runs, starting with Codex and documenting the equivalent Claude capability boundary. A running agent must consume a post-launch steer (including a progress request) without restart, then emit correlated accepted, delivered, applied or rejected, and terminal evidence. Prove replay/idempotency and unavailable-adapter behavior; never infer delivery from logs, prose, tmux state, or terminal keystrokes. | [late-steering implementation ticket](docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md#late-steering-adapter-implementation-ticket) | -| BKL-003 | P1 | done | Seal bounded raw executor stream evidence before normalization and add provider adapters that explicitly label usage as `delta`, `cumulative`, or `terminal`. Implement cached/cache-write/reasoning mappings, fail-closed mode semantics, retry provenance, and provider/local cost separation. | [provider evidence research and implementation](docs/PROVIDER_EVIDENCE_RESEARCH.md) | -| BKL-004 | P1 | ready | Run a controlled real-executor deterministic cohort. Pin executor/model/environment/tool policy, record capability calibration, retain raw and sealed evidence, publish an explicit baseline/candidate manifest with exclusions, and run `agent-workflow eval compare`. | [research: cohort protocol](docs/Durable_Orchestration_Delivery_Benchmarks.md#real-executor-cohort-protocol) | -| BKL-005 | P1 | done | Extend trial evidence only where a sealed provider receipt proves it: source digests, retry/re-steer/error accounting, provider-billed versus locally estimated cost, currency rules, incomplete-trial rejection, and comparison validity. | [provider evidence research and implementation](docs/PROVIDER_EVIDENCE_RESEARCH.md) | -| BKL-007 | P1 | ready | Add opt-in, installer-owned host routing enforcement: preserve existing hooks, block only exact raw delegation launch patterns, provide an audited break-glass path, and test Codex rules/instructions separately from Claude Code `PreToolUse` hooks. | [global routing decision](docs/GLOBAL_AGENT_ROUTING.md) | -| BKL-008 | P1 | done | Track bounded durable context and assignment history for interactive agents; require explicit completion, rank same-worktree candidates, and restrict automatic reuse to exact ticket/retry lineage with correlated acknowledgement. | [interactive reuse](README.md#reusing-an-interactive-agent) | -| BKL-009 | P0 | done | Complete the 0.2.1 post-release critical review: repair scheduler reconciliation/capacity/authority, retry replay and descendant recovery, immutable workflow storage/journal handling, score-set verification, provider-evidence ambiguity handling, projections, tests, documentation, and release assets. | [0.2.1 critical review](docs/execution-evidence/FINAL_CRITICAL_REVIEW.md) | +| BKL-001 | P0 | ready | Add durable per-consumer message cursors and idempotent handling dispositions. Prove restart recovery, duplicate safety, and cursor advancement only after successful handling. | [Operations](docs/OPERATIONS.md#durable-messages) | +| BKL-002 | P0 | ready | Add executor-specific post-launch steering for detached runs. A running executor must consume a steer without restart and emit correlated delivered/applied/rejected evidence; terminal text or process liveness is not proof. | Strict future journey in `tests/future/test_late_steering_journey.py` | +| REL-001 | P0 | needs-decision | Select and add the project license, matching package metadata, and distribution policy. | [Public release readiness](docs/PUBLIC_RELEASE_READINESS.md#blocking-decisions) | +| REL-002 | P0 | blocked | Establish a real monitored vulnerability-reporting channel and update `SECURITY.md`. | [Public release readiness](docs/PUBLIC_RELEASE_READINESS.md#blocking-decisions) | +| REL-003 | P0 | ready | Define the supported Linux/Python/tmux/executor matrix and run live compatibility journeys on representative clean hosts. | [Testing](docs/TESTING.md#live-compatibility) | +| BKL-004 | P1 | ready | Run a controlled real-executor baseline/candidate cohort with pinned model, executor, environment, tools, cache policy, repetitions, exclusions, and sealed evidence. | [Evidence and evaluation](docs/EVIDENCE_AND_EVALUATION.md#cohort-comparison) | +| BKL-007 | P1 | ready | Add opt-in installer-owned host routing enforcement only for narrowly defined direct delegation commands, with preserved hooks and an audited break-glass path. | [Operations](docs/OPERATIONS.md#host-routing) | +| MCP-003 | P1 | ready | Add idempotent pack validation, worktree creation, bounded launch, workflow validate/start/status/resume, progress, ack, and steer tools through existing services. | [MCP server](docs/MCP_SERVER.md#planned-mutation-phase) and [`prompt-packs/mcp-server-next/`](prompt-packs/mcp-server-next/) | -### Workflow foundations (completed in 0.2.0) - -The implemented architecture and explicit non-targets are recorded in [Workflow Foundations](docs/WORKFLOW_FOUNDATIONS_PLAN.md). The ticket IDs below remain canonical history and evidence anchors. - -| ID | Priority | State | Depends on | Work and exit evidence | Reference | -|---|---|---|---|---|---| -| WF-001 | P0 | done | — | Make prompt-pack dependencies a validated cross-phase DAG: reject malformed, unknown, self, and cyclic dependencies. | [implemented foundation](docs/WORKFLOW_FOUNDATIONS_PLAN.md#dependency-graph-validation) | -| WF-002 | P0 | done | WF-001 | Add optional ticket-specific JSON result contracts, bounded handoff collection, schema validation, sealed result artifacts, and collection receipts. | [implemented foundation](docs/WORKFLOW_FOUNDATIONS_PLAN.md#structured-task-result-contracts) | -| WF-00 | P0 | done | WF-002 | Define the minimal workflow snapshot, event, node-state, retry-lineage, and service contracts. Exit with versioned schemas/contracts and focused validation tests. | [ticket](prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md) | -| WF-01 | P0 | done | WF-00 | Implement restart-safe dependency scheduling over the existing launch service with bounded parallelism and no alternate executor path. Exit with scheduler service tests covering dependency eligibility, failures, retries, and concurrency bounds. | [ticket](prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md) | -| WF-02 | P0 | done | WF-01 | Add workflow CLI/status/resume behavior and prove restart reconstruction from durable records. Exit with CLI, recovery, and interrupted-run tests. | [ticket](prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md) | -| WF-10 | P1 | done | WF-02 | Add receipt-backed approval gates that unblock only after a valid immutable review disposition for the expected revision. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md) | -| WF-11 | P1 | done | WF-10 | Add bounded JSON Pointer result binding from validated child results, with type/size limits and no arbitrary file reads. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md) | -| WF-12 | P1 | done | WF-11 | Seal aggregate workflow receipts that commit to the workflow snapshot, event stream, node bindings, child final receipts, approvals, and terminal disposition. | [ticket](prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md) | -| WF-20 | P1 | done | WF-12 | Add only the three authorized reusable graph templates: pipeline, bounded parallel review with fan-in, and implementation followed by independent review. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md) | -| WF-21 | P1 | done | WF-20 | Add deterministic, explainable routing advice that remains subordinate to existing class, executor, model, and no-go policy enforcement. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md) | -| WF-22 | P1 | done | WF-21 | Perform the integration, security, documentation, release-asset, and full-suite review for the complete workflow foundation. | [ticket](prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md) | -| WF-006 | P2 | deferred | BKL-003, BKL-004, BKL-005 | Consider evidence-derived routing recommendations only after sealed, comparable executor cohorts exist. No online learning or vector-memory dependency. | [routing boundary](docs/WORKFLOW_FOUNDATIONS_PLAN.md#explainable-routing-advice) | - -### MCP server work - -The read-only local stdio MCP adapter is implemented. WF-22 is complete, so MCP-003 is ready for a separately scoped implementation. MCP must wrap stable workflow and lifecycle services rather than create parallel orchestration semantics. - -| ID | Priority | State | Depends on | Work and exit evidence | Reference | -|---|---|---|---|---|---| -| MCP-001 | P1 | done | — | Audit reusable domain seams and define typed MCP request/result contracts without changing CLI lifecycle semantics. MCP and CLI invoke shared service functions. | [implementation report](docs/MCP_SERVER_IMPLEMENTATION_REPORT.md) | -| MCP-002 | P1 | done | MCP-001 | Add the optional pinned official Python SDK dependency and local stdio server with bounded read-only run/pack resources, traversal/redaction controls, capability negotiation, and tests. | [implementation report](docs/MCP_SERVER_IMPLEMENTATION_REPORT.md) | -| MCP-003 | P1 | ready | WF-22 | Add validated `pack_validate`, `worktree_create`, single-run `launch`, workflow validate/launch/status/resume, `progress`, `ack`, and `steer` tools through existing services. Require idempotency and durable evidence mapping; steering remains `pending` without correlated acknowledgement. | [MCP pack Phase 3](prompt-packs/mcp-server-next/phase-3/README.md) | -| MCP-004 | P2 | deferred | MCP-003 | Add policy-gated interrupt/terminate and review/accept/reject tools, then run representative-host and security evaluation. Workflow approvals must reuse workflow review receipts. | [MCP decision: MCP-3 and MCP-4](docs/MCP_SERVER_DECISION.md#phased-implementation-plan) | -| MCP-005 | P2 | decision | MCP-004 | Authorize or reject Streamable HTTP only after local stdio adoption and security evidence; require a separate authorization ADR before implementation. | [MCP decision: MCP-5](docs/MCP_SERVER_DECISION.md#conditional-http-evolution) | - -## Blocked on supplied prerequisites +## Blocked prerequisites | ID | Priority | State | Missing input and exit evidence | Reference | |---|---|---|---|---| -| BKL-010 | P1 | blocked | Provide a pinned browser-image digest, font manifest, and a verified pre-seal browser/Inspect evidence bridge. Then implement the priority-picker Playwright fixture with DOM, keyboard, ARIA, screenshot, and explicit child-lifecycle telemetry gates. | [blocked-gate report](docs/PHASE_3_BLOCKED_GATE_REPORT.md) | +| BKL-010 | P1 | blocked | Supply a pinned browser-image digest, font manifest, and verified pre-seal browser/Inspect evidence bridge before implementing the visual priority-picker fixture. | [Evidence and evaluation](docs/EVIDENCE_AND_EVALUATION.md) | -## Decisions required before implementation +## Decisions -| ID | Priority | State | Decision required | Reference | -|---|---|---|---|---| -| DEC-001 | P0 | needs-decision | Set the durable-control service objective: storage location/failure model, ordering scope, producer model, exactly-once external-effect requirements, and maximum no-wakeup steering latency. Record the decision before changing journal topology. | [research: open questions 1-6](docs/Durable_Orchestration_Delivery_Benchmarks.md#open-questions) | -| DEC-002 | P1 | needs-decision | Set benchmark policy: required first executors, billing meaning, warm-cache role, replicate count/effect threshold, and handling of interrupted or human-assisted trials. | [research: open questions 9-14](docs/Durable_Orchestration_Delivery_Benchmarks.md#open-questions) | -| DEC-003 | P2 | deferred | Authorize multi-host orchestration only when a concrete cross-host consumer or local wakeup/scan service objective fails. If authorized, choose JetStream first unless an existing Redis dependency is mandated; retain the canonical durable record envelope and idempotency requirements. | [research: Stages B-D and Priorities 6-7](docs/Durable_Orchestration_Delivery_Benchmarks.md#recommended-staged-architecture) | -| DEC-MCP-001 | P1 | decided | MCP is optional and Python is the implementation language. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-002 | P1 | decided | Start with MCP Inspector/local stdio, protocol `2025-11-25`, and SDK `mcp==1.28.1`. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-003 | P1 | decided | Restrict access to configured repository, pack, worktree, and state roots. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-004 | P1 | decided | Omit destructive tools until MCP-3; force kill remains excluded. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-005 | P1 | decided | Use `mcp-stdio:` for local actor identity. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-006 | P1 | decided | Use optional `mcp` extra and `agent-workflow-mcp` entry point with public SDK APIs. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-007 | P1 | decided | Exclude raw terminal capture initially; any future view is bounded and observational. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | -| DEC-MCP-008 | P1 | decided | Require a separate authorization ADR before non-stdio transport. | [MCP decision: approved decisions](docs/MCP_SERVER_DECISION.md#approved-maintainer-decisions) | +| ID | Priority | State | Decision | +|---|---|---|---| +| DEC-001 | P0 | needs-decision | Set the durable-control service objective: storage/failure model, ordering scope, producer model, external-effect idempotency, and maximum no-wakeup latency. | +| DEC-002 | P1 | needs-decision | Set benchmark policy: first executors, billing meaning, cache role, replicate count/effect threshold, and treatment of interrupted or human-assisted trials. | +| DEC-003 | P2 | deferred | Authorize multi-host orchestration only after a measured single-host failure. Preserve replayable durable records as authority; prefer JetStream unless an existing Redis dependency is mandated. | +| DEC-MCP-HTTP | P2 | deferred | Authorize any non-stdio MCP transport only through a separate security ADR after local adoption evidence. | ## Deferred architecture -| ID | Priority | State | Work and trigger | Reference | -|---|---|---|---|---| -| ARC-001 | P2 | deferred | Add a transport-neutral advisory notifier interface with tmux and filesystem-watch adapters only after a latency/operability need is measured. Replay plus bounded reconciliation remains mandatory. | [research: Stage B](docs/Durable_Orchestration_Delivery_Benchmarks.md#stage-b--transport-neutral-notifier-interface) | -| ARC-002 | P3 | deferred | Add a reconstructable SQLite materialized index only when JSONL replay/scan cost is measured as a problem; never make two stores independently authoritative. | [research: Stage C](docs/Durable_Orchestration_Delivery_Benchmarks.md#stage-c--durable-record-indexing) | -| ARC-003 | P3 | deferred | Add a multi-host broker adapter, shared-artifact record references or canonical envelope replication, and cross-trust signing only after DEC-003. | [research: Stage D and open questions 7-8](docs/Durable_Orchestration_Delivery_Benchmarks.md#stage-d--optional-multi-host-broker) | +| ID | Priority | Trigger | +|---|---|---| +| ARC-001 | P2 | Add a transport-neutral notifier only after measured wakeup latency or operability requires it; replay remains mandatory. | +| ARC-002 | P3 | Add a reconstructable SQLite index only after JSONL replay/scan cost is measured as a problem. | +| ARC-003 | P3 | Add a multi-host broker, shared-artifact references, and cross-trust signing only after `DEC-003`. | +| MCP-004 | P2 | Add policy-gated review/disposition and interrupt/terminate tools after `MCP-003`; force kill remains excluded. | +| WF-006 | P2 | Consider evidence-derived routing recommendations only after comparable real-executor cohorts exist; no online learning or vector-memory dependency. | ## Completed history -| ID | Completed in | Result | Evidence | -|---|---|---|---| -| HIST-001 | 0.1.5 | Durable fsynced control records, best-effort tmux wakeups, visible same-window panes, usage accumulation, verifier timing, and immutable `eval collect`/`eval compare` landed. | [implementation completion plan](docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md), commit `6b61cbb` | -| HIST-002 | 0.1.6 | Global editable installer now installs core Python dependencies and retains its pip-managed launcher. | [installation guide](docs/INSTALLATION.md), commit `306c6f5` | -| HIST-003 | 0.2.0 | Completed workflow approvals, result binding, aggregate receipts, templates, routing, provider evidence, and trial-evidence hardening; removed unused vendored MCP SDK source. | [0.2.0 historical gate](docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md) | -| HIST-004 | 0.2.1 | Corrected workflow child authority, active-capacity accounting, restart reconciliation, retry/dependency replay, immutable snapshot/journal locking, projection recovery, provider usage validation, and score-set receipt authority after an independent critical re-review of 0.2.0. | [0.2.1 critical review](docs/execution-evidence/FINAL_CRITICAL_REVIEW.md) | - -## Reference map - -- [Durable orchestration and benchmark research](docs/Durable_Orchestration_Delivery_Benchmarks.md): prior art, recommended stages, evidence envelope, cost rules, cohort protocol, and open questions. -- [Messaging and regression-eval status](docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md): contracts and implemented/blocked status. -- [Wakeup and evidence completion plan](docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md): completed implementation plan and acceptance history. -- [Visual-eval blocked gate](docs/PHASE_3_BLOCKED_GATE_REPORT.md): exact external prerequisites. +| Release | Summary | +|---|---| +| 0.1.x | Worktrees, tmux lifecycle, durable state, prompt packs, evaluation, provider adapters, skills, and packaging foundations. | +| 0.2.0 | Workflow DAGs, approvals, result binding, aggregate receipts, templates, routing advice, and provider/trial evidence. | +| 0.2.1 | Authority, replay, locking, symlink, scorer-receipt, provider-accounting, and immutable-input hardening. | +| 0.2.2 | Acceptance-first installed-product tests, compact invariant matrices, strict future TDD journeys, CI, and public-documentation consolidation. | diff --git a/CHANGED_FILES.md b/CHANGED_FILES.md deleted file mode 100644 index 3ee93ff..0000000 --- a/CHANGED_FILES.md +++ /dev/null @@ -1,70 +0,0 @@ -# Changed files - -**Release:** 0.2.1 -**Baseline:** delivered 0.2.0 commit `4ece38c` -**Scope:** critical-review repair delta only - -This ledger lists every tracked path changed by the 0.2.1 repair. Status codes use Git name-status semantics. - -- Modified: 55 -- Added: 0 -- Deleted: 0 -- Renamed: 0 - -| Path | Status | -|---|---| -| `BACKLOG.md` | M | -| `CHANGED_FILES.md` | M | -| `CHANGELOG.md` | M | -| `CLEANUP_AND_REMOVAL_AUDIT.md` | M | -| `FEATURE_TEST_LEDGER.md` | M | -| `MANIFEST.sha256` | M | -| `SESSION_RESTORE.md` | M | -| `VERSION` | M | -| `agent-workflow.yaml` | M | -| `docs/ARCHITECTURE.md` | M | -| `docs/MCP_SERVER_DECISION.md` | M | -| `docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md` | M | -| `docs/MCP_SERVER_IMPLEMENTATION_REPORT.md` | M | -| `docs/PROVIDER_EVIDENCE_RESEARCH.md` | M | -| `docs/WORKFLOW_FOUNDATIONS_PLAN.md` | M | -| `docs/diagrams/REPOSITORY_CHART_PACK.md` | M | -| `docs/diagrams/provider-evidence.mmd` | M | -| `docs/diagrams/workflow-scheduler.mmd` | M | -| `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md` | M | -| `docs/execution-evidence/PHASE_GATE_REPORT.md` | M | -| `docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md` | M | -| `docs/man/agent-workflow-mcp.1` | M | -| `docs/man/agent-workflow-workflow.1` | M | -| `docs/man/agent-workflow.1` | M | -| `prompt-packs/mcp-server-next/MANIFEST.sha256` | M | -| `prompt-packs/mcp-server-next/README.md` | M | -| `prompt-packs/mcp-server-next/pack.yaml` | M | -| `pyproject.toml` | M | -| `schemas/workflow-snapshot.schema.json` | M | -| `src/agent_workflow/__init__.py` | M | -| `src/agent_workflow/approval.py` | M | -| `src/agent_workflow/bindings.py` | M | -| `src/agent_workflow/cli.py` | M | -| `src/agent_workflow/doctor.py` | M | -| `src/agent_workflow/eval/scoring.py` | M | -| `src/agent_workflow/eval/trials.py` | M | -| `src/agent_workflow/lifecycle.py` | M | -| `src/agent_workflow/provider_evidence.py` | M | -| `src/agent_workflow/receipts.py` | M | -| `src/agent_workflow/runner.py` | M | -| `src/agent_workflow/scheduler.py` | M | -| `src/agent_workflow/sessions.py` | M | -| `src/agent_workflow/util.py` | M | -| `src/agent_workflow/workflow.py` | M | -| `src/agent_workflow/workflow_receipt.py` | M | -| `src/agent_workflow/workflow_service.py` | M | -| `tests/test_approval.py` | M | -| `tests/test_eval_reporting.py` | M | -| `tests/test_eval_trials.py` | M | -| `tests/test_lifecycle.py` | M | -| `tests/test_provider_evidence.py` | M | -| `tests/test_receipts.py` | M | -| `tests/test_session_launch.py` | M | -| `tests/test_workflow.py` | M | -| `tests/test_workflow_receipt.py` | M | diff --git a/CHANGELOG.md b/CHANGELOG.md index 5758e28..422e7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ No unreleased changes. +## 0.2.2 — 2026-07-25 + +- Replace the 239-test, 46-file implementation-heavy suite with installed-wheel acceptance journeys, compact security/state/accounting matrices, release checks, opt-in live compatibility, and strict expected-failure future specifications. +- Exercise real public CLI journeys for installation surfaces, Git worktrees, external executors, durable messages, retries, structured provider evidence, workflow approvals, sealed result binding, aggregate receipts, interactive-agent reuse, prompt packs, and evaluation comparisons. +- Publish the previously missing `evaluation-runtime` schema after the installed evaluation lifecycle exposed that sealed collection could not validate its own runtime contract. +- Remove private-helper and mock-driven tests that primarily locked parser, dictionary, command-vector, or prose assumptions. +- Add GitHub Actions coverage for Python 3.11 through 3.13 and document the acceptance-first test policy. +- Consolidate operations, prompt-pack, evidence/evaluation, MCP, testing, and public-release guidance into a small canonical documentation set. +- Remove completed prompt packs, ticket-completion ledgers, session checkpoints, changed-file/cleanup artifacts, and historical design reports from the public source surface while preserving history in Git. +- Reduce the MCP follow-on pack to the single active mutation phase and align it with shared-service, idempotency, evidence, and acceptance-test requirements. +- Add contributing, support, public-release-readiness, testing-strategy, and release-path documentation and diagrams. +- Mark license selection, vulnerability reporting, supported-host compatibility, and release ownership as explicit blockers rather than implying public-release readiness. + ## 0.2.1 — 2026-07-24 - Reconcile running workflow nodes from verified child provenance and sealed terminal evidence; count existing running nodes against parallelism and require a durable child footprint before recording `running`. diff --git a/CLEANUP_AND_REMOVAL_AUDIT.md b/CLEANUP_AND_REMOVAL_AUDIT.md deleted file mode 100644 index 20ae65a..0000000 --- a/CLEANUP_AND_REMOVAL_AUDIT.md +++ /dev/null @@ -1,60 +0,0 @@ -# Cleanup and removal audit - -**Release:** 0.2.1 -**Updated:** 2026-07-24 - -This audit distinguishes material removed in this release from optional historical material and generated files that must not be shipped. Paths are repository-relative. - -## Removed in the 0.2.0 workflow-completion pass - -| Path | Disposition | Reason | -|---|---|---| -| `src/agent_workflow/mcp/sdk/` | removed, 141 tracked files | Unused vendored copy of the official MCP Python SDK. It was not imported by runtime code, was excluded from package discovery, duplicated upstream maintenance/security work, and added about 1.8 MB of drift. Runtime and tests use the pinned optional dependency through public APIs. | -| `src/agent_workflow/mcp/SDK_SNAPSHOT.md` | replaced by `SDK_DEPENDENCY.md` | The old name implied a maintained vendored snapshot. The replacement records the exact external dependency/tag/commit without carrying third-party source. | - - -## Reviewed in the 0.2.1 critical repair - -- Consolidated workflow journal parsing on the descriptor-safe locked reader instead of maintaining a second receipt-specific parser. -- Retained the workflow lock as a single small durable coordination file; rejected symlink/non-regular substitutions rather than introducing a daemon, database, or broker. -- Removed generated caches and build output before packaging. -- Re-reviewed historical prompt packs and execution evidence. They remain explicit provenance, not runtime dependencies or active task trackers. -- Found no additional vendored dependency, duplicate scheduler, compatibility layer, project-specific adapter, or unused runtime subsystem that could be removed safely. - -## Generated material removed before packaging - -These paths are safe to delete at any time and are excluded from release archives: - -- `.pytest_cache/` -- every `__pycache__/` directory and `*.pyc` file, including those under source, tests, and deterministic fixtures; -- Python build output such as `build/`, `dist/`, and `*.egg-info/` when generated locally; -- editor/OS temporary files and test scratch directories. - -## Historical removals retained in Git history - -Earlier cleanup passes removed one-off delivery artifacts (`VALIDATION.md`, `IMPLEMENTATION.patch`, and the root `IMPLEMENTATION_REPORT.md`) and an unrelated project-specific adapter/test. They remain absent. They are not repeated as current 0.2.0 source changes. - -## Optional archival candidates, not removed - -These are not runtime dependencies, but they retain useful provenance or reusable examples. Remove them only under an explicit repository-history policy: - -| Path | Why it can be archived | Why it remains now | -|---|---|---| -| `docs/execution-evidence/` | Completed ticket/gate reports are not imported at runtime. | Provides reviewable release provenance and records the original WF-10 rejection and correction boundary. | -| `prompt-packs/agent-workflow-skills-and-mcp/` | Completed historical input pack. | Useful for reproducing the skill/MCP foundation. | -| `prompt-packs/orchestrator-messaging-evals/` | Completed historical input pack. | Preserves the durable messaging/evaluation design lineage. | -| `prompt-packs/workflow-foundations-next/` | Superseded as an execution queue by the completed implementation. | Authoritative ticket contracts and prior independent-review history remain valuable. | -| `prompt-packs/chatgpt-workflow-completion-next/` | Completed input pack. | Retained as the exact implementation instruction set executed for 0.2.0. | -| `examples/three-phase-pack/` | Not runtime code. | Maintained user-facing example and validation fixture. | - -## Reviewed and retained intentionally - -- `scripts/`: compatibility entry points and release/prompt-pack tooling remain exercised. -- `templates/` and `src/agent_workflow/assets/`: both source and packaged copies are required; release auditing checks synchronization. -- `tests/fixtures/` and `evals/fixtures/`: deterministic regression inputs, not user/runtime debris. -- historical `CHANGELOG.md` and `BACKLOG.md` entries: release history, not stale current-state documentation. -- the read-only MCP implementation under `src/agent_workflow/mcp/`: active code; only the unused vendored SDK subtree was removed. - -## Simplification conclusion - -No second scheduler, executor launch path, database, broker, daemon, HTTP service, online-learning layer, arbitrary terminal steering mechanism, or compatibility shim was introduced. The largest confirmed drift source was the vendored SDK and it has been eliminated. Remaining optional historical packs/evidence are explicit provenance rather than hidden runtime complexity. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c72cc63 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +This repository is pre-public-release. Contributions should stay within the current terminal-first scope and begin with an issue or backlog entry before substantial implementation. + +## Development setup + +```bash +python -m pip install -e '.[dev]' +pytest +``` + +Run the complete release gate before submitting changes: + +```bash +./scripts/release-check.sh +``` + +## Change rules + +- Preserve one canonical execution path shared by CLI and MCP surfaces. +- Treat status files and terminal output as projections, not authorities. +- Add tests through the acceptance-first structure in [docs/TESTING.md](docs/TESTING.md). +- Extend an installed-product journey before adding a narrow invariant. +- Do not add completed prompt packs, one-off implementation reports, generated caches, vendored SDK source, or local paths. +- Update the backlog for unfinished work and the changelog for user-visible completed work. +- Keep documentation links, CLI help, man pages, examples, schemas, and skills aligned with behavior. + +Security-sensitive changes should include adversarial path, symlink, replacement, replay, and crash/restart analysis where applicable. + +## Scope + +Automatic merging, autonomous model selection, remote execution, web UI, daemonization, and network MCP transport are out of scope unless the backlog records an explicit maintainer decision. diff --git a/FEATURE_TEST_LEDGER.md b/FEATURE_TEST_LEDGER.md deleted file mode 100644 index 833713e..0000000 --- a/FEATURE_TEST_LEDGER.md +++ /dev/null @@ -1,37 +0,0 @@ -# Feature and test ledger - -**Release:** 0.2.1 -**Updated:** 2026-07-24 -**Purpose:** Current evidence map for behavior that materially affects orchestration authority, restart safety, provider accounting, and the planned MCP mutation surface. - -This ledger is a release aid, not a substitute for immutable run/workflow receipts. A feature is marked `pass` only when its contract has focused executable coverage and the related documentation matches the implementation. - -| Feature or boundary | Status | Evidence and protected failure | -|---|---|---| -| Durable message journal and tmux wake hint | pass | `tests/test_messages.py` and `tests/test_tmux.py` prove replay-first authority, fsynced append behavior, bounded waits, and fallback when tmux wakeups are lost or unavailable. | -| Workflow contract and replay | pass | `tests/test_workflow.py` validates schemas, contiguous event sequencing, legal transitions, snapshot identity, descriptor-safe immutable reads, lock/journal symlink rejection, restart reconstruction, active-capacity accounting, authoritative child reconciliation, retry lineage, projection recovery, and dependency-failure reopening. | -| Receipt-backed approval gates | pass | `tests/test_approval.py` reconstructs the canonical append-only lifecycle chain, ignores mutable status projections, rejects symlinked receipt roots, lock files, non-regular or writable receipts, and fails closed on copied, omitted, duplicated, or tampered evidence. | -| Bounded result binding | pass | `tests/test_bindings.py` validates the supported RFC 6901 subset, ancestry, result/collection digests, per-value and aggregate size limits, optional/missing behavior, immutable parent snapshots, read-only child launch copies, and byte-stable replay. | -| Aggregate workflow receipts | pass | `tests/test_workflow_receipt.py` binds the normalized snapshot, durable event stream, exact node terminal states, retry attempts, child seals, bindings, and canonical approval evidence. Substitution, omission, duplication, partial state, post-seal approval tampering, and receipt/artifact symlink redirection fail verification. | -| Authorized workflow templates | pass | `tests/test_workflow_templates.py` covers only pipeline, bounded parallel-review/fan-in, and implementation-independent-review expansion and checks deterministic output. | -| Deterministic routing advice | pass | `tests/test_routing.py` verifies stable explanation codes, recommendation/enforcement separation, disagreement reporting, and fail-closed no-go policy. Launch still passes through the canonical session/config policy. | -| Provider stream evidence | pass | `tests/test_provider_evidence.py` covers bounded stable raw-stream capture, symlink/change rejection, event digests/sequences, identified replay idempotency with conflicting-ID rejection, ambiguous unidentified deltas, explicit delta/cumulative/terminal modes, monotonic totals, finite nonnegative usage, cached/cache-write/reasoning fields, terminal conflict rejection, complete currency/catalog metadata, and provider-billed versus catalog-estimated cost separation. | -| Trial collection and comparison | pass | `tests/test_eval_trials.py`, `tests/test_eval_compare.py`, and evaluation command tests reject incomplete/unsealed evidence and forged mutable score sets, validate regular read-only content-addressed scorer receipts against the exact verified final-seal digest, preserve nulls, bind source digests, and exclude currency/catalog mismatches from cost comparisons. | -| Sealed artifact path integrity | pass | `tests/test_receipts.py` covers stable final-receipt reads, exact artifact digests, optional-tree immutability, descriptor-based chmod, final/intermediate symlink rejection, and outside-target protection. | -| Retry accounting | pass | `tests/test_metrics.py` proves retry count is derived from sealed provenance or workflow attempt lineage rather than mutable session status. | -| Read-only MCP adapter | pass | `tests/test_mcp_services.py` and `tests/test_mcp_server.py` cover bounded resource access, configured-root containment, traversal/symlink rejection, redaction, protocol initialization, and the existing read-only/validation capability surface. | -| MCP mutation tools | not implemented | `MCP-003` is ready now that `WF-22` is complete. The proposal and threat model require reuse of canonical services, idempotency, durable result identifiers, and no raw shell/tmux/path authority. | -| Real-executor benchmark cohort | not run | `BKL-004` remains a controlled external execution task. No provider/model winner or production routing recommendation is claimed from synthetic fixtures. | -| Fresh independent external-agent review | unavailable in this environment | Historical independent reviews accepted WF-00 through WF-02 and rejected the original WF-10 status-path trust flaw. The corrected cumulative tree has focused tamper tests and a local critical review; no new Codex/Claude executable was available, so no external receipt is fabricated. | - -## Release-gate commands - -The final command results for this delivery are recorded in `docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md` and `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md`. The release manifest must be regenerated after every source, documentation, schema, skill, prompt-pack, or test change. - -## Interpretation rules - -- Terminal capture is observational and never proves lifecycle state, usage, approval, or message delivery. -- `status.json` is a projection; canonical append-only records and sealed artifacts are authoritative. -- Unknown tokens, cost, currency, retry details, or durations remain `null`; they are not inferred from prose. -- A best-effort tmux wakeup may be lost, duplicated, or unavailable without losing a durable message. -- MCP is an adapter over repository services. It must not become a parallel workflow engine or expose arbitrary shell, filesystem, environment, or tmux control. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index eb8bbe6..1c095ef 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -1,70 +1,40 @@ 7a97c19d87b6c07e6a28c16e18b7a1816fba6a2da530f8c488dba3f8e2f65b3a .cbmignore +ac10fe6191b605dc02fb0f9bd3291b2b6e5f5c02d175887b9771fce002186fca .github/workflows/ci.yml 9d4865b65c62212d1e2213e6d5305458e6442d23338bb484ef05417e748cc12a .gitignore -1156b0d190d727ad7b4a25f7cec456c8a08bbe3452789f631ac5caaf08bc03e3 BACKLOG.md -c951d5b90c4626e5aaffc1791a42ed1dc75799206075dfadf3b0421f2c96159d CHANGED_FILES.md -b6f26db97ae0dd00a5d47636cda3c7e2a2ce6e9dcaaf729f7d607cc3c6d23dc9 CHANGELOG.md -2ab99b71cb8dcea158e8e2678be31f8ab919d12e1ea97468ee0e9a870d1ff907 CLEANUP_AND_REMOVAL_AUDIT.md +a18302e5fcf47bc999ec41d8770e2e51071b5b4684638078bb658fa2e2676f2d BACKLOG.md +295b210fcff6c78367ea8febc4ea07df5ae59c64b00e005985e2977c4e31d58f CHANGELOG.md +e7e23f9a5c7c3e0d165a0d8a1d05cfee49927d4092441f6bb4293f1f06de21ee CONTRIBUTING.md a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -893ac876ece3a0617d24e43085292c1f18d5ce9ce50b0aa3b27795039627126e FEATURE_TEST_LEDGER.md -60cab04a3946f6ea21d245cb871a5e86333c85dfd90dc8943ab8adb926e5b0a7 Makefile -6407c73dbcda6070f42927cf1da588e0038a33d7c39bdca37538b1f457445d97 README.md -76f912155dbfee01d0275a42a0ee636b2aa8f5bd87bb343e98db31082e706d53 REMOVED_PATHS.txt -194ea1d829a11a08aa356befcc72607e37d948e5b16ab9bc0b08788547b22595 SECURITY.md -0aa6f5de99829d5b30d9e15a1661961c9d70c9e81baedd896ffc3bf5d57cd60a SESSION_RESTORE.md -71015c979ccb0fc8a0be7ca0ae83046ab045cdc2c8faa09fb2f0f7e440f9b4a6 VERSION -8346c780f5e7fe16eae74db2661dd0f817be70fc89bd5e6fccc4439736543f5b agent-workflow.yaml +f371b3a72ac17d213f876aad5cde663aa0447a76df53b4acd3ca87d9cd6d1a6e Makefile +0e6f6f585e7b5859069543ea1540e4fc24c5059f721b83c6993108756b2a2b7a README.md +8c0847fb83d694b29259092912ac921863fa7cfdb236fffff1d56ea4d4dd8409 SECURITY.md +93ed60530e84668852ce2b53eb10f681790058f2f7d916d85dc29a3557363864 SUPPORT.md +998f6b887ed7a6ef44e84210d0237b715bed42ea7254f48dc418afec0a484103 VERSION +a04c774cc13a7ada2ff98364ebbb1c98129271c70bf56ce9673f23d09ac33612 agent-workflow.yaml de632c1a29079d61fafa72732c60b34414d92d2aab4004948be39291e363ffbf bin/agent-workflow a1a9011fe663f0e7f126cb3e1541ead76bebe11cb832a1edd81a8f9aeed3a3a3 config/agent-workflow.example.toml -c3f1b77c023226bae4e9e3698475c8cf61fb6c5cc5d161118dbc6a2baacbd612 docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md -afb8f6af1237f2fa5d833f0f3636b85169a671e5792207eb9a6e61d9c80d5ee3 docs/ARCHITECTURE.md -375b5108dbc46fb23b58d32afaedbd3ac06020b14824df073d919d7bf6eb7512 docs/COMMAND_REFERENCE.md -7bd3d0bf460dd4313298f26f92814b6117b223527221efdc2ca5b63b81eaf5ae docs/DELEGATION_LIFECYCLE.md -840b4553d6d166a9063e3644e5466739749b8312ac5c896b0d2126346eddd29a docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md -de70c2fa9c3e57791005ea29e411703d95f8ab27e1d7c8a5227ac2c3b9e33f20 docs/Durable_Orchestration_Delivery_Benchmarks.md -e08fff30d5fee8cae8208b5b88c0ccf3825d117df5a6ead2383fb463fd5056d2 docs/GLOBAL_AGENT_ROUTING.md +4866e9ee8510916e6ee44e9816e1b64935ce8da72649af334455cf1595e08f47 docs/ARCHITECTURE.md +aa2143c9d973c5e14accc6eda8d03fc239db1321a94b891f10a0b41f0231aae1 docs/COMMAND_REFERENCE.md +e68d8294ece387170a76b9c3760764055d12fa7eecc03f965acc169a31925f79 docs/EVIDENCE_AND_EVALUATION.md a1698eee63c79213c836d2d89a55be7dce801c0403e8b4ef339b23c6b32dd2de docs/INSTALLATION.md -6a87ccf0ccc9f4c6ae88ee8044c0631f379df2463ea7f2c1d07c0c9703d4e635 docs/MCP_IMPLEMENTATION_CHECKLIST.md -59a8fd116cba9f5053836d9b4db595ba74bf1d374d4736fbf3a85b67468dec3f docs/MCP_SERVER_DECISION.md -c84998f1d6a1dedf20538b3980b13c30e117a6b039305d23ff22158b9b33acda docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md -5bbbd48ccaa303b94546a479d0fd4010c159f1a45745a3cde4311720d92fb262 docs/MCP_SERVER_IMPLEMENTATION_REPORT.md -dc186be21e1dd7275df7b88a312b0c2efdb0e6e328319c60c9c9683b836f0b75 docs/MCP_THREAT_MODEL.md -558f7954a686fcfd9d3ebaaaed374a67d9d113765e9d59602a79d5b25d2577e2 docs/MCP_TOOL_AND_RESOURCE_CATALOG.md -659af09c0923a77bd5b4e5418c416706959a821446a3368a569b739205dc36ef docs/MESSAGING_PRIOR_ART_REPORT.md -6594aa6a195f6517f5434686273840a8d4a0b1c371fb9ecdccb6126783af1f17 docs/MIGRATING_EXISTING_ASSETS.md -5ce0539252fdffb7aaa4c2aa3b035d7888333c691f7fa51473a2ebf34bcc9e7a docs/MODEL_TIERS.md -6d7912023dbc0148dd4735afe6f590fc5a5ab5291b1f9655dc386fe359d1a7f1 docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md -4e9c8d903abc99c531c8fc0f15cb723534dbb373615c351fa43ffcfd43c1816c docs/PHASE_3_BLOCKED_GATE_REPORT.md -c18a375de14669c1589849ee794836340ed6bbe16556eaa92fd545e8ce60ae18 docs/PROMPT_PACK_STANDARD.md -40ce83ed1ce1a500e87a31bb386d0e76eaf7d212009af3b8fb5eaa70e7330a83 docs/PROVIDER_EVIDENCE_RESEARCH.md -82f37eadbf373864d7e9f49a63d7143b80f3fa0dfe767d62fe5c81cd70462ad8 docs/SECURITY_TRUST_BOUNDARIES.md -eefdc7d02b26c9ffc34ec2e1e948a7533f237deca50275f3fc0debcc82e22b53 docs/STALL_RECOVERY.md -a4cf89724dd97f387f755b9469b51f6893738d27daa50ddb11094b1c5525bdd8 docs/TEST_POLICY.md -8f4ce5089a93fa09197344808f94cdc4b7076d3c5a314ac157c80971e347480f docs/WORKFLOW_FOUNDATIONS_PLAN.md +81106240b84a3d4380fe0418bfa311497edce9ea4147d89e2bfe03b06ceefb22 docs/MCP_SERVER.md +2411c4b5fd63fdfa0de8093f6d9a9c7a3f891413a96c94da6c8485a690c5dce3 docs/OPERATIONS.md +181b77db0787b3d1fe4ee4cc63e7d7a2d13d3a1f04318b97de8bc66eefe7f399 docs/PROMPT_PACKS.md +1e9cc4fbb3946172ee0741e707938cefb29a8da8561240be7a1d46e94013dd26 docs/PUBLIC_RELEASE_READINESS.md +c79af597a75d7c64c8dc838802d038eb0cb38a72b5cb6fc52e321c3d71d832d6 docs/TESTING.md f045545a381be5fb8c9e86449f322fdddf47f5fbf241fbede156110ab60e9096 docs/adr/0001-inspect-evaluation-topology.md -2e6bfc5b2ca69f6e274706a36b922f54d62306df43e9ab5772e953c58b495f97 docs/diagrams/REPOSITORY_CHART_PACK.md +d58737df1783d5f3adf6166a482483a9b3bda876817a1182b20c5682f04dd532 docs/diagrams/REPOSITORY_CHART_PACK.md 8d513f339b56f329ba8893b8aee847d7a3e665ece1784d8296ebe1c3816700d7 docs/diagrams/authority-hierarchy.mmd 116c4419252f15fab3d38eab07e42a90d275f9d2580fc13869e9246911c57360 docs/diagrams/evidence-erd.mmd 0d331614deafdb7931211d6334f2ac2f05a5d10459391420edc80dd4d61b1d2e docs/diagrams/mcp-target-architecture.mmd 0cfea84a9a87a03b85ed59b9ff380dba90b7e2a6dfdbbeda065f4ce47d231f76 docs/diagrams/provider-evidence.mmd +8c3725adf6631d0fe86419df4d115322793a78e9c49e350bdc999d707b2b3101 docs/diagrams/public-release-path.mmd +c1a7c2d80811586988ecbc15ea862df3ad7b1023d264da3637c5633ebb19b3a7 docs/diagrams/testing-strategy.mmd 97997a5fcc25b181ca413f346bf6cf50f1556a05ffb527249d9ca7c6795501c0 docs/diagrams/workflow-scheduler.mmd -2db45629ac8f0980cea4de70a76f68baa9b36e724efda18ca051e9cc2d5098a6 docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md -33ac85d90c1289893d93dfd083e918bf1abb623828fa551d2be616050c3dade2 docs/execution-evidence/BKL-003-TICKET-COMPLETION.md -e155cd9421693831291f0a51de6cceb427b9e7f0d33d735d45b14ec3ea8173bc docs/execution-evidence/FINAL_CRITICAL_REVIEW.md -9fc3010cbf4e3d83261278ad356163720e5a5cf342dc2db3ebe7b5b9363826b8 docs/execution-evidence/P0-00-TICKET_COMPLETION.md -5ea9903db4dbdbcbc2e19f7f546ed99b0b33f06d1e474b49bddc4d6bc0b1bccb docs/execution-evidence/P0-01-TICKET_COMPLETION.md -d5f189a7532b235d8ebcd09f4acfc371d489a2bec4ba17f3ad0fcca155d1ebe4 docs/execution-evidence/P1-00-TICKET_COMPLETION.md -69c7e24d2a576a9ad0006e38933500e4af5ebce3632804d677231f4bdd259d2c docs/execution-evidence/PHASE_GATE_REPORT.md -7fa90ddec36704c43f2f90cf0c7b0d7ca2fc71393a869cf69684c240fb8b156a docs/execution-evidence/WF-10-TICKET-COMPLETION.md -67eb670a8318241dccce853841557eda0624d513d065dc13967ee9506398ccb7 docs/execution-evidence/WF-11-TICKET-COMPLETION.md -8195c3802da759b2d038579abf340c1394112ca4526cb8ccd5b3e2666296477b docs/execution-evidence/WF-12-TICKET-COMPLETION.md -a39c56695180f0c4f239ef25d3a8f01889791534fa3d0ea268588b4af544c6ea docs/execution-evidence/WF-20-TICKET-COMPLETION.md -6cbd0388f17ec3c627060a342d218d344d9e68dfcf6d943ad5a19a1d25364a31 docs/execution-evidence/WF-21-TICKET-COMPLETION.md -ca47971358348fa5e946b683ca81335f426a03c45f7e1988ad8d19de0e574df2 docs/execution-evidence/WF-22-TICKET-COMPLETION.md -9059a24b560fcd3da029128a8b2e25b1905cf479de5453b0794448d9bd604003 docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md -3b69902ef91783fd5c6035d25e9f28592b3af2d70136197419d821a4b72114b4 docs/man/agent-workflow-mcp.1 -68efa03f0976505f79698d78ecf7c08a47af7abd909ecde768b6e4959bdaf66a docs/man/agent-workflow-workflow.1 -86b5a84878df2e1c011477c2d4dea0de5a82a25ac301e8115add43796eda2bf7 docs/man/agent-workflow.1 +13ae2caeb573a67216d44c38a4c377ff3324c0324c91ad8d0ea8d8ed826d5b28 docs/man/agent-workflow-mcp.1 +6f2587ee61942eff42b9d93138091dc44f8476a207c11103c6a391cf2ed2db38 docs/man/agent-workflow-workflow.1 +fb4e0562bcfa076141a2084e6ffbbaa83dfc45cd42544083bb8a32c833488ae4 docs/man/agent-workflow.1 8c39b1b3724a7b7184a3cc0f74a5d79f4b12d0e0a38de65049ef772e74cd5d6c evals/Dockerfile c2128d6c1e7c46f0c127a8b74eed590fa27dddf20a04c981d6dc0899ad8f1c54 evals/README.md 6c70613d77cdcce6dfd85e098c29f798c7c24b6050e636919ebb0c4a2c98ca60 evals/datasets/development.jsonl @@ -99,105 +69,20 @@ c8e521eaa4a88914bc6138b6244592d9cfdf8bc632865c6cd0d83b20773ba2d2 examples/three ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c examples/three-phase-pack/templates/PHASE_GATE_REPORT.md c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 examples/three-phase-pack/templates/TICKET_COMPLETION.md 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 examples/three-phase-pack/templates/source-baseline.example.json -c0259d8068d5bf07de9e278bf1c1842ea3a9a023edcb2760084d0eaa07475270 install.sh -9447bd4c55903c0c4f71297ded3ec6af5f525a63392fb4e8301cac7d131af7c8 prompt-packs/agent-workflow-skills-and-mcp/DELEGATION_RUNBOOK.md -9d00aca121e09f2eb80e649a2b0588373df4e8188e54349ea78e4e21d61b1049 prompt-packs/agent-workflow-skills-and-mcp/EXECUTION_PROTOCOL.md -9ed192f7bdf66cd8193517554a434b553a0e534ee1cab1026be8f313a569a7b9 prompt-packs/agent-workflow-skills-and-mcp/README.md -7c148518377de91eae7f25c71de53b712da0eba9f61b7a9594855d6c2e014629 prompt-packs/agent-workflow-skills-and-mcp/pack.yaml -756ca0878f48fd8712beaad6cfa1f9fc50cc683ce47e4ea953a96416eb05c06b prompt-packs/agent-workflow-skills-and-mcp/phase-0/MASTER_IMPLEMENTATION_PROMPT.md -9109e76f3244994fea47f6b0f3f2680723d5d4493a1adb1e8da7538b1fb209fc prompt-packs/agent-workflow-skills-and-mcp/phase-0/README.md -d8dfdbd532bf31f1640ceb3d6540f8a0ce2e2640b3f433365bc829d0cce2b0bc prompt-packs/agent-workflow-skills-and-mcp/phase-0/task-manifest.yaml -71979d92fb60472333497b56440163954c1f1e5e65538ef1240713c2f587df28 prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-00-baseline-and-preflight.md -99a890705553271420e05d435ce2edba51be6b8e8c5681f3ce39b634bae8ae44 prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-01-skill-cli-integration.md -d0eb98de8affc3561ef7dbaf9c74401fe833f4c1efabdb2ee2dc6bd616e3843a prompt-packs/agent-workflow-skills-and-mcp/phase-1/MASTER_IMPLEMENTATION_PROMPT.md -d688128be4505631480a072e5aea3f57d4992e4531d2182329b8925f58d3b941 prompt-packs/agent-workflow-skills-and-mcp/phase-1/README.md -e368ea14161623cf0966de41a6efeeb5858c3864c392ce695e20f6f5f57fb69a prompt-packs/agent-workflow-skills-and-mcp/phase-1/task-manifest.yaml -6815f76e700ad26762836bca546de4f256c48f2de4e56886d21291ffe5be53db prompt-packs/agent-workflow-skills-and-mcp/phase-1/tickets/P1-00-mcp-server-decision.md -4dfde1d611f0b3a959d9d7a6d93430c7fd55a321be3adac63c2ae8b5aa6ec604 prompt-packs/agent-workflow-skills-and-mcp/references/README.md -a0b6f85ecfc917ea634e2d31901765369c5656a8f588c54594ccc8f7ed340d4b prompt-packs/agent-workflow-skills-and-mcp/references/code-structure-outlines.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/agent-workflow-skills-and-mcp/scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/agent-workflow-skills-and-mcp/scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 prompt-packs/agent-workflow-skills-and-mcp/scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 prompt-packs/agent-workflow-skills-and-mcp/scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 prompt-packs/agent-workflow-skills-and-mcp/scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 prompt-packs/agent-workflow-skills-and-mcp/scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 prompt-packs/agent-workflow-skills-and-mcp/scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f prompt-packs/agent-workflow-skills-and-mcp/scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/agent-workflow-skills-and-mcp/templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/agent-workflow-skills-and-mcp/templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/agent-workflow-skills-and-mcp/templates/source-baseline.example.json -7e0976f8f782192607ad0fb6c8b88306d7647228e787f87f7c814267cb4e892e prompt-packs/chatgpt-workflow-completion-next/CHATGPT_HANDOFF_PROMPT.md -5bb7334ba2b658d9810f1c0a8780c5eb51acec4597f85d3ddc73fe97935e7ea1 prompt-packs/chatgpt-workflow-completion-next/DELEGATION_RUNBOOK.md -adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 prompt-packs/chatgpt-workflow-completion-next/EXECUTION_PROTOCOL.md -7a4f042f4b9c6935c9b2cfee509f0783dc94f20fe7ad335ed0b5d2b4a5d5ec62 prompt-packs/chatgpt-workflow-completion-next/README.md -a34bd83458605c47cc4f74aa3c1261dc4f6f185647521e8916738d46508d1276 prompt-packs/chatgpt-workflow-completion-next/contracts/workflow-node-result.schema.json -a70069129389ff6c4f8affe3fb94d8b91e8b42b1a72a69dd6bfd45e392f979a5 prompt-packs/chatgpt-workflow-completion-next/pack.yaml -f7523df45911ef669bcc812d26f8cef88a1ca8f8aa2f4fe5ac05dbd5a2321247 prompt-packs/chatgpt-workflow-completion-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md -c72159dcbc2f216655a37e25bc1c843219ce0d0d0376369b3902a96f60459e80 prompt-packs/chatgpt-workflow-completion-next/phase-0/README.md -da114439c42bc72666375c5f693ad3a975fe2deb931963c3d038de5117f63c2e prompt-packs/chatgpt-workflow-completion-next/phase-0/task-manifest.yaml -325f6722e52dda55f8b565640f2dfa71bb25c12a7a44a6c078a1db7ee62416de prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-00-contract-and-state.md -8d0d431572c396e5f580455c459367f84d1ba375da5f74a6230bea37edfe59c5 prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-01-scheduler-service.md -0ed1811b6960deca79bdf16ad6601ecf1f39d5a8d8841c2af001bba42015269d prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-02-restart-and-cli.md -be0c18b729ce86203318c5f0604f800f197026dc38d70336009a97fdbdfa6f05 prompt-packs/chatgpt-workflow-completion-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md -7ce12c4c9bb6ecd96c2cb3210063cf5f8a1fba4e7cbdbec061728e1ba58981f9 prompt-packs/chatgpt-workflow-completion-next/phase-1/README.md -1586d81810dca16935cf763d16f15a2f0e526674e60b5eae8a1beaf5d74a179e prompt-packs/chatgpt-workflow-completion-next/phase-1/task-manifest.yaml -b595b2a035988910d0e7315852d0f3d9b68b4039d8dd9e8a5a6726794faaf22e prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-10-approval-gates.md -0a6f2bcb59ef8b1abd2a038dbe7344ca78e5872c3b5269e4d09fcf330955e20b prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-11-result-binding.md -2ad9a00fb19ddd2533d6db54c97f1805fc3bf91ad1ec2571cebf17b78ff3839d prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-12-workflow-receipt.md -54511b2b64f1b90dca042b12bea9bed0080a6f89f8b910f2120c24079ae3f17c prompt-packs/chatgpt-workflow-completion-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md -e4b9938f2d00d52f56d288ac97751b7e4972053d36e9c54ad6133164c10d937a prompt-packs/chatgpt-workflow-completion-next/phase-2/README.md -b1a5a2c32148f7adb1e6b4df2753823ee7c087d4c8ed5b97dba0e7e31884f800 prompt-packs/chatgpt-workflow-completion-next/phase-2/task-manifest.yaml -e4a9948cda12a880e465ddc911f06583f96aaf5f520d8494ec539a9d17974d9b prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-20-templates.md -d4932ccc4b6c4734745451cd76c439b42e78cdd36d7289d020bea44f5e5ddeb3 prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-21-routing-advice.md -504c047b3cb460439975726ca8b891acdd0f5c6b379d196541c4cf5a6ae8064e prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-22-integration-review.md -0480ea0d3b5437b7a5b732786c8b4504ab79d761ed8168168d4c481c366e518e prompt-packs/chatgpt-workflow-completion-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md -7b4150861c904b1288bbb47a488050f9560300322f20627edf4776355f055222 prompt-packs/chatgpt-workflow-completion-next/phase-3/README.md -a31211d7aba32b9f13c4ae77b210b66d2678c52618dc5c90f2d7ef5270f41259 prompt-packs/chatgpt-workflow-completion-next/phase-3/task-manifest.yaml -1e092212995cce013d5737a3a3a9d1d14d46a8da24e8a0d6816dd603efc31ed6 prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003-RESEARCH.md -d10b15b3c1e73f58cecd40c5113f98987061b9efa058739d4c55f5a905ab71a4 prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003.md -2276f05f42f628925cd0b0834d7044e5934a488f85f4a57d5b22b023009ae17f prompt-packs/chatgpt-workflow-completion-next/references/README.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/chatgpt-workflow-completion-next/scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/chatgpt-workflow-completion-next/scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 prompt-packs/chatgpt-workflow-completion-next/scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 prompt-packs/chatgpt-workflow-completion-next/scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 prompt-packs/chatgpt-workflow-completion-next/scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 prompt-packs/chatgpt-workflow-completion-next/scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 prompt-packs/chatgpt-workflow-completion-next/scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f prompt-packs/chatgpt-workflow-completion-next/scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/chatgpt-workflow-completion-next/templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/chatgpt-workflow-completion-next/templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/chatgpt-workflow-completion-next/templates/source-baseline.example.json -838a6333e2fd160f71c8d3d6610a6e0521e412f92181f5332f3ceba1f696110b prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md +374b3593873ae0dc260b0de30969a66bad1affaf92b108336f9187555c962fc7 install.sh +a9d1763e3b90ed11068b7532bf4d42b24fae93ec57210ba761869c7d8ed9cf74 prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 prompt-packs/mcp-server-next/DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 prompt-packs/mcp-server-next/EXECUTION_PROTOCOL.md -40f034a20cbcbb928fec310bbe56b27c73104ea48108b792677fc26de352be64 prompt-packs/mcp-server-next/README.md -4a4b64b2de7997a23d3f36365c8afc05cef4462771a7a19bc653c4c705690abf prompt-packs/mcp-server-next/pack.yaml -d4e8b40779a4293ad8f15face0b4cd3b37ec78b79f005b910bde644f329ec3be prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md -d9c303351fd8f94fd9191f5aa97b704609af94f4ffb45ae188c8d041f4cbce5b prompt-packs/mcp-server-next/phase-0/README.md -e27d2cd479f66651472ba2a942b8503eaef5c23c5c7986fb06afa8fb2dfe03e1 prompt-packs/mcp-server-next/phase-0/task-manifest.yaml -ea63a1bf8f3ef5c4ca13d866001776c3cfb043d6ccb80ef9e0663b9246cd2ae8 prompt-packs/mcp-server-next/phase-0/tickets/P0-00-baseline-and-preflight.md -e037b22bd3df3c487a7b557f9867dc8177b85a13086cd259e3ad394382aa758c prompt-packs/mcp-server-next/phase-0/tickets/P0-01-primary-research-refresh.md -8e5345cbb70d1c11d253401bc095123c3bd97be032f936610d8048c87b819c31 prompt-packs/mcp-server-next/phase-0/tickets/P0-02-architecture-review-and-plan.md -99c116e2a12cc001a74aa9af5586b17f051306a1e834e5ff3a1f9066a19e4b20 prompt-packs/mcp-server-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md -076699a54b1193ca70e27b6be44db93ed458cbf063bdd29cb9bdafbe86feece9 prompt-packs/mcp-server-next/phase-1/README.md -abe24390206ea3e9db2c27cb8c65435a3f29814b1b38454eff2daf99f71ed519 prompt-packs/mcp-server-next/phase-1/task-manifest.yaml -a99ad2d2765aa6e2a26420ea2ffa335ee83fd75578037b315e8c3d2d766872c6 prompt-packs/mcp-server-next/phase-1/tickets/P1-00-baseline-and-preflight.md -be3c6af65e201c5e3f4f20a731f63370cbcfd9db1b554ba4ce2e877a9f0c490d prompt-packs/mcp-server-next/phase-1/tickets/P1-01-domain-services-and-contracts.md -6815d3ee60ef8c5a1777662ab9f4f5603ed06af1d6760a73b9319f06efa84f8b prompt-packs/mcp-server-next/phase-1/tickets/P1-02-independent-phase-review.md -5d44b831ff83feac27855da08c0c3a2e5f9cb8711e7cc09ba0b7c37148f41ba4 prompt-packs/mcp-server-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md -18fc626cecbcb371fb62a305a7617988788282a1458e31ef729adb58ab34f94e prompt-packs/mcp-server-next/phase-2/README.md -7ad6aab7aee3305e0da60c06fd26b3bddb568ed05b01301c8458c55b240846d8 prompt-packs/mcp-server-next/phase-2/task-manifest.yaml -aa4be85a853fb104aec89537c5d2da26bfb272ce408a9f37c85fca22a1c8754e prompt-packs/mcp-server-next/phase-2/tickets/P2-00-baseline-and-preflight.md -fc188e6af28d74dda7d5264baada5d1445b71b74c800d6fc9c28205bbe47ff46 prompt-packs/mcp-server-next/phase-2/tickets/P2-01-read-only-stdio-server.md -4a993bd750d1ecfeea89e376d0804be281b8549bef798f9b8826c6b66c5c893a prompt-packs/mcp-server-next/phase-2/tickets/P2-02-security-conformance-review.md -9e66e5e37da8057c5640b15dcc644ce323b60b7f3178cfa3a9e85504d8913cfc prompt-packs/mcp-server-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md -8cc14937de6d179f066ee90715f0f816f2d6bcfbe08684043fd31616f8ad721b prompt-packs/mcp-server-next/phase-3/README.md -a27a9fc70ecc65ca8cbffb728bcb6da1690df49515b5fde28f98e6593bee63a6 prompt-packs/mcp-server-next/phase-3/task-manifest.yaml -434147effe66cbae328a54182bf85625a25f0246be8fa3c75595eb0dca901894 prompt-packs/mcp-server-next/phase-3/tickets/P3-00-workflow-baseline.md -902538b2ed4099776e9a8411f99a611cf5e864d2641ff40e3cdd96e10cc88d4b prompt-packs/mcp-server-next/phase-3/tickets/P3-01-workflow-aware-tools.md -613d19df7075e45fc90c137ac0a52fa843e56d09ab381a46ee43a79507fd1795 prompt-packs/mcp-server-next/phase-3/tickets/P3-02-independent-review.md -4346f5ab221a1a9eac21fdff24315c7c49d0a192b45f5afece8da8f48b591872 prompt-packs/mcp-server-next/references/README.md -cc792c567a4900dd9f270ad5c7eec835d4b8bc6eed9353a31d093759476036c6 prompt-packs/mcp-server-next/references/code-structure-outlines.md +f12e66a7da9b0f9b3d23ab0c7feccf1a45e20198f7b7f7c6a527fca86dc3594f prompt-packs/mcp-server-next/README.md +a40ead589126162ee8d85249287e4c8950672f3abba28a7901223387f4813909 prompt-packs/mcp-server-next/pack.yaml +ce3aa6e86c1a8b044c6400a8b6cf3d60843351d6da8bade1a9e850147dc96a08 prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +fa6a1bbe6159a85f58f8c5e350f54a858571523b7226ae38dfdda4c9179edd9d prompt-packs/mcp-server-next/phase-0/README.md +19f9897f63f24ef7a967d583fcf2a9286ca99ec47e0aa05548a1c10985845ea1 prompt-packs/mcp-server-next/phase-0/task-manifest.yaml +7178ca60aed3d7ab9f3e42f4bb7f82a5567ad547224d557fb856999f35cbc058 prompt-packs/mcp-server-next/phase-0/tickets/MCP3-00-workflow-baseline.md +d3c16e5e2ed702a2c4c89271d6331a93b873afec068a23a366d8432beb90c971 prompt-packs/mcp-server-next/phase-0/tickets/MCP3-01-workflow-aware-tools.md +2fa96703e1a52d9314b3c68337eea36bcce40a8c7c11729381a54548f4c6e4fd prompt-packs/mcp-server-next/phase-0/tickets/MCP3-02-independent-review.md +8dda54259ebca6babf59b4033361d1544b1dab427c3124787b903472c82ecaa3 prompt-packs/mcp-server-next/references/README.md +4a0559b3b242f49355c0b16c043f4271926f8991bdf7356c3655f570d66e2a34 prompt-packs/mcp-server-next/references/code-structure-outlines.md 4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/mcp-server-next/scripts/archive-prompt-pack.sh 004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/mcp-server-next/scripts/check-delegation.sh 0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 prompt-packs/mcp-server-next/scripts/create-ticket-worktree.sh @@ -209,76 +94,7 @@ cc792c567a4900dd9f270ad5c7eec835d4b8bc6eed9353a31d093759476036c6 prompt-packs/m ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/mcp-server-next/templates/PHASE_GATE_REPORT.md c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/mcp-server-next/templates/TICKET_COMPLETION.md 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/mcp-server-next/templates/source-baseline.example.json -a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 prompt-packs/orchestrator-messaging-evals/DELEGATION_RUNBOOK.md -adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 prompt-packs/orchestrator-messaging-evals/EXECUTION_PROTOCOL.md -e05631ee2ae858a4a04dd7522ce2e5caa22267424bf46e1759db2a55b9524c7d prompt-packs/orchestrator-messaging-evals/README.md -d895854f3a22fdc80e2db0bcc1090d38b29712236365ee084ec4e74aab754af5 prompt-packs/orchestrator-messaging-evals/pack.yaml -d2d8a5c6f6df66d56f33afc62a0c28d8e6154a3248545f483db9186032327e67 prompt-packs/orchestrator-messaging-evals/phase-0/MASTER_IMPLEMENTATION_PROMPT.md -758e65dff03aae34fa83cef88035aa1349139c0307341aea35429bf2276e3a48 prompt-packs/orchestrator-messaging-evals/phase-0/README.md -650be9beb8b5d55d78fcf9f8b4ae45d21f2fb30f638cd2d3b37afe685b8f04cf prompt-packs/orchestrator-messaging-evals/phase-0/task-manifest.yaml -678ecaec8c33374f36177d5efe2630d0e37adb873062ef2fda9daaec8027757d prompt-packs/orchestrator-messaging-evals/phase-0/tickets/P0-00-baseline-and-preflight.md -5d9db8a9c979459415bd9fa98a8b92458abb0f4268ebcbb514dd2a74b681d402 prompt-packs/orchestrator-messaging-evals/phase-1/MASTER_IMPLEMENTATION_PROMPT.md -3e1a8c06794440908b56c70918e0989df47d67c41581b6b60f7f42ec03f33dae prompt-packs/orchestrator-messaging-evals/phase-1/README.md -baa8a60428ba83b03366f57fc5b88b037163b633e5738a3724e50a3186eb5a3b prompt-packs/orchestrator-messaging-evals/phase-1/task-manifest.yaml -8466bd6517d28d632a2938d194a3531fe5beb37d3fbbe840d8bf1aee001349ee prompt-packs/orchestrator-messaging-evals/phase-1/tickets/P1-00-baseline-and-preflight.md -ea0d802a71906712a25507f6e7981c75fe4601f29bb6a36fdd5ab5e222878a1f prompt-packs/orchestrator-messaging-evals/phase-2/MASTER_IMPLEMENTATION_PROMPT.md -21f7af8d49bd22b668c62eb71658ec218f5ebf8c21f6dc46b478346525bd28d8 prompt-packs/orchestrator-messaging-evals/phase-2/README.md -d89cc35d90d16e0b551c43fe9b2d8ff103f8ac5890e8e5ea80b48908aca4f3f1 prompt-packs/orchestrator-messaging-evals/phase-2/task-manifest.yaml -c72dae7a44f74266e42880ddfcf6b7772ee649c73a33d9fbc4c55b3b6dfec5b6 prompt-packs/orchestrator-messaging-evals/phase-2/tickets/P2-00-baseline-and-preflight.md -df3e1e9413734c729a4e01e335b5f0294a8be21625ca388acc7174b780b97e65 prompt-packs/orchestrator-messaging-evals/phase-3/MASTER_IMPLEMENTATION_PROMPT.md -397cb16213501f022d3c1e053309e629495e146865273d187f4c081c60d1b589 prompt-packs/orchestrator-messaging-evals/phase-3/README.md -7f29dffd7f17c7d8abb5b16a2f8fb71f427c12f5067cdca5ce8542f00bfd34dc prompt-packs/orchestrator-messaging-evals/phase-3/task-manifest.yaml -b0ef35119797f7da5ef42c49c0c298b107a2b0a792202fb9f3f9be180b833095 prompt-packs/orchestrator-messaging-evals/phase-3/tickets/P3-00-baseline-and-preflight.md -058254d1ca44128deec0c84eeba241a1c96d0c04da5d236fe78250ef42501ddb prompt-packs/orchestrator-messaging-evals/references/README.md -a0b6f85ecfc917ea634e2d31901765369c5656a8f588c54594ccc8f7ed340d4b prompt-packs/orchestrator-messaging-evals/references/code-structure-outlines.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/orchestrator-messaging-evals/scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/orchestrator-messaging-evals/scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 prompt-packs/orchestrator-messaging-evals/scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 prompt-packs/orchestrator-messaging-evals/scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 prompt-packs/orchestrator-messaging-evals/scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 prompt-packs/orchestrator-messaging-evals/scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 prompt-packs/orchestrator-messaging-evals/scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f prompt-packs/orchestrator-messaging-evals/scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/orchestrator-messaging-evals/templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/orchestrator-messaging-evals/templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/orchestrator-messaging-evals/templates/source-baseline.example.json -7463d40674de941d9a181193befa920f66f9b1c127edbc847f0bd628cbd296c6 prompt-packs/workflow-foundations-next/CHATGPT_HANDOFF_PROMPT.md -a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 prompt-packs/workflow-foundations-next/DELEGATION_RUNBOOK.md -adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 prompt-packs/workflow-foundations-next/EXECUTION_PROTOCOL.md -068d67532b134b833a122a4b442b6adb31e8dc613858036b20d0f26f1d3f1ec4 prompt-packs/workflow-foundations-next/README.md -a34bd83458605c47cc4f74aa3c1261dc4f6f185647521e8916738d46508d1276 prompt-packs/workflow-foundations-next/contracts/workflow-node-result.schema.json -8bfb8d3eaae1cd49dcb33ec4814dbe7f17fec17eecb8f8b10239d913d8b16c9f prompt-packs/workflow-foundations-next/pack.yaml -f7523df45911ef669bcc812d26f8cef88a1ca8f8aa2f4fe5ac05dbd5a2321247 prompt-packs/workflow-foundations-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md -c72159dcbc2f216655a37e25bc1c843219ce0d0d0376369b3902a96f60459e80 prompt-packs/workflow-foundations-next/phase-0/README.md -da114439c42bc72666375c5f693ad3a975fe2deb931963c3d038de5117f63c2e prompt-packs/workflow-foundations-next/phase-0/task-manifest.yaml -325f6722e52dda55f8b565640f2dfa71bb25c12a7a44a6c078a1db7ee62416de prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md -8d0d431572c396e5f580455c459367f84d1ba375da5f74a6230bea37edfe59c5 prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md -0ed1811b6960deca79bdf16ad6601ecf1f39d5a8d8841c2af001bba42015269d prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md -be0c18b729ce86203318c5f0604f800f197026dc38d70336009a97fdbdfa6f05 prompt-packs/workflow-foundations-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md -7ce12c4c9bb6ecd96c2cb3210063cf5f8a1fba4e7cbdbec061728e1ba58981f9 prompt-packs/workflow-foundations-next/phase-1/README.md -1586d81810dca16935cf763d16f15a2f0e526674e60b5eae8a1beaf5d74a179e prompt-packs/workflow-foundations-next/phase-1/task-manifest.yaml -b595b2a035988910d0e7315852d0f3d9b68b4039d8dd9e8a5a6726794faaf22e prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md -0a6f2bcb59ef8b1abd2a038dbe7344ca78e5872c3b5269e4d09fcf330955e20b prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md -2ad9a00fb19ddd2533d6db54c97f1805fc3bf91ad1ec2571cebf17b78ff3839d prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md -54511b2b64f1b90dca042b12bea9bed0080a6f89f8b910f2120c24079ae3f17c prompt-packs/workflow-foundations-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md -e4b9938f2d00d52f56d288ac97751b7e4972053d36e9c54ad6133164c10d937a prompt-packs/workflow-foundations-next/phase-2/README.md -b1a5a2c32148f7adb1e6b4df2753823ee7c087d4c8ed5b97dba0e7e31884f800 prompt-packs/workflow-foundations-next/phase-2/task-manifest.yaml -e4a9948cda12a880e465ddc911f06583f96aaf5f520d8494ec539a9d17974d9b prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md -d4932ccc4b6c4734745451cd76c439b42e78cdd36d7289d020bea44f5e5ddeb3 prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md -504c047b3cb460439975726ca8b891acdd0f5c6b379d196541c4cf5a6ae8064e prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md -2276f05f42f628925cd0b0834d7044e5934a488f85f4a57d5b22b023009ae17f prompt-packs/workflow-foundations-next/references/README.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be prompt-packs/workflow-foundations-next/scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 prompt-packs/workflow-foundations-next/scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 prompt-packs/workflow-foundations-next/scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 prompt-packs/workflow-foundations-next/scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 prompt-packs/workflow-foundations-next/scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 prompt-packs/workflow-foundations-next/scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 prompt-packs/workflow-foundations-next/scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f prompt-packs/workflow-foundations-next/scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 prompt-packs/workflow-foundations-next/templates/source-baseline.example.json -874d8676b582dc2b075a1fec2f507f45cdbb0e16058b5cbd6a65f8b6ec229a22 pyproject.toml +ecb18f122f967f8f33f2905b01e402ae3ca5c53e2baad828792618b73e3d9a02 pyproject.toml 3156d2a36c05428fd993983bee48c491d7792da4634a721f098af876c1c9fc5a schemas/agent-context.schema.json 4f4da8261b8a9946b477b694bed5610b581aaf3722ac8460194711ec4c685587 schemas/assignment-event.schema.json 260790055c6f13515a4dce9704c3847259eb1d664dba8b628bae367814223564 schemas/command-collection.schema.json @@ -287,6 +103,7 @@ c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 prompt-packs/w d0150f14606effb18ba6524715659bfa36f9ce3811a7fdcc8f960d04f4ed7a4a schemas/completion.schema.json 2cad48f6b60762f99f0a2cf1a4f95f28eee9ca749d2d351782709ca2014b8d22 schemas/control-event.schema.json aa0cc367995b74db5d9670544d0aebbb14cc14c54b23bf469fb5916d3d2864db schemas/evaluation-plan.schema.json +e6c6f81802d5366efa3affaccb08654e435b9c4830ba39b1c59625eec7fddac4 schemas/evaluation-runtime.schema.json 400a6ce6b8f19818f3e4b7454d6302bfd015f316f7c49f5cdfa9737d264d3d36 schemas/execution-metrics.schema.json 89893912dadfd53d8e4415cc93860b32960cdedea2aea487655ea0b80f0cc9f1 schemas/final-receipt.schema.json c500bfce18c4024d49b834c28dd3e7227de014b29e5462ab30487fda71af2430 schemas/job-binding.schema.json @@ -315,21 +132,21 @@ b2187bbe04c39bf78055919c68c9f0a526ca36fd1b9e887161ddf45d596fc579 schemas/workfl f291140c90b98cfeb1ebcfed89ccaf0f273c2a1e0148a85d70e055e48a2eb569 schemas/workflow-status.schema.json 303f47ee6d2345c19b98511a79c8c5086ff16365af81b58886f2fed1d74fb4d4 scripts/README.md 4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh -fb28b072d332c090b0b85415435c12d440c26d78223235f67af0d96607c905e6 scripts/audit-release-assets.py +a71d7b27decbda7051c8644a5b163fffd60d72c0d4227d1a47252021a46b8f23 scripts/audit-release-assets.py 004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh 0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh 040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 scripts/foreground-delegation.sh 944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 scripts/launch-delegation.sh -f3de06fb3b15782ce2f9e9aadfbbfadbef80bce990476b81bd62976fdbbc91fc scripts/release-check.sh +ead6e1fe1ef7f116cb536b733bed8aef9091b010a492ebda31127c8c014d4295 scripts/release-check.sh 964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 scripts/restart-delegation.sh a5070c546b6621ad93688381e2113a8c0a915d87262238a327637236d0f7f158 scripts/session_status.py 19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh 614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -beb6f1f022e68b77043eba1aa83f7e7deb79783c544ba8c30cdfdce17cf2ad3b skills/agent-workflow-orchestrator/SKILL.md +126079ebb71d46a94c757a45d0aabb9a4976e461c1809440b4b1e1722c73c96e skills/agent-workflow-orchestrator/SKILL.md 9a8be67f1d197ec814493cda285c8cea12d7a8b02f3b7374fb75087097e39f62 skills/delegated-implementation/SKILL.md 0bf899f4b80c74fa937b92920528bd323bb388baad9758be3a502ad7c45e0fdb skills/phase-gate-review/SKILL.md 6fc060c52b099b8cb9da79826fcc8b2bf69a692feca6eb45fb9a37b175611e08 skills/prompt-pack-builder/SKILL.md -0fc0d8cd5126d9db40cced7b41ce1a7ef93578e108577a8ed557af959a6c89a8 src/agent_workflow/__init__.py +e3a489757820fe5717ac7164cc784b362e13a5419f968d69e61429e8fff06da8 src/agent_workflow/__init__.py 79ee6f134c5c5193377cf9b189cbdcc3e2095e49ba9cec310114441c15a917c4 src/agent_workflow/__main__.py 6bf4351172ab5490651bd768abebccf3a1c00d64d2a55d02eb6598ae311854cf src/agent_workflow/agent_context.py fe5f331833d19f67aa23c1a01a50d7bd218cedd4304364558944d2be11be6bbd src/agent_workflow/approval.py @@ -356,11 +173,11 @@ c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 src/agent_work 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 src/agent_workflow/assets/prompt-pack-root/templates/source-baseline.example.json a72623d39d13fc7532983587c2af4b45f104a73bfc45f8129ad7d076c7642008 src/agent_workflow/assets.py 1bb5a1767626d4117f94b7ca10033d517226884c9eb73ce1119fc7dfaaa9f79a src/agent_workflow/bindings.py -f723d20784e58c3ca39a55972343032a6fc55d0ae192644d38b378cf5f29a600 src/agent_workflow/cli.py +013619904f2ce81ef7890824ab9519a04506dc97e9e467f6b476911ef9f609f0 src/agent_workflow/cli.py 228d3b1bf04a6681ba138712d91510b64c0e97c1cf76de2cf9cd23e8626a37bc src/agent_workflow/config.py 2ce05b732595d51818c28ab121a75cede32d3de354de0da588815caf7c71f9e7 src/agent_workflow/contracts.py 8c75b2df9f1fa58afd914d3d74f23524b3be41c1c08573e0ac5b2d66c1d4ae63 src/agent_workflow/diagnostics.py -a3e24230389be62dae1312f674bffd4e77dcb960c5f35f825229782d62b19e26 src/agent_workflow/doctor.py +64700b0ce7fd687b3597af8c073863d733090029d894cc499a8463e06b81c6f2 src/agent_workflow/doctor.py a08ecdff9fc1860261166deeb03edc4b5d7f4fa98d909b4e6ed211752fd2950c src/agent_workflow/errors.py e847deb2e0e5f5f015f718aa6d0cc9c558efc5251d4092a6abbbefd926588dc7 src/agent_workflow/eval/__init__.py 2845344237e92ca8d36d747adb3203ebc395ad932c3ca05c5b0d3ddf0d476546 src/agent_workflow/eval/commands.py @@ -428,59 +245,18 @@ ae7ba301efd8ad36c723243deee102ffb093424e51167dfd662b03fec8b80e85 templates/prom 614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f templates/prompt-pack/scripts/validate-prompt-pack.sh 2e1801da0db8cad81bf4698eddd70a72e556c718f1a9bd35d635f20cf0e0e6d6 templates/prompt-pack/task-manifest.yaml 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 templates/source-baseline.example.json -cef1371462d05a3e7087e624580d71afdd2c41ca5400734c0ba594397afc9ec3 tests/fixtures/native-jobs/valid-job.json -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 tests/fixtures/regression-evals/deterministic-json/app/__init__.py -a181c0a24c9efe7fb18a90d291ea3f5c8c364f713d503cd4cf30f31ca408f14e tests/fixtures/regression-evals/deterministic-json/app/normalize.py -ccb2388f9f681aa9d08266a539cdb5043347675293f24be3e7336b27b4ccd649 tests/fixtures/regression-evals/deterministic-json/evaluation.json -40fda919fba1d42b5cf612619a2cac5bae01b2c8614cb5a912786506f597e1c5 tests/fixtures/regression-evals/deterministic-json/mutations/first_wins.py -00b8aa1b3555e67f1897a917b5b38590f3c3a38b3d4f8d25ba7cbfbb81c2d990 tests/fixtures/regression-evals/deterministic-json/mutations/mutates_input.py -774b2753a43646fbd3641ea9e5bb2f4e8ec94f92a5546475ab6c99d6a13fb2c5 tests/fixtures/regression-evals/deterministic-json/mutations/no_dedup.py -582d7d19cb0f85e9be6064892d1d6cc789457f8a9e1762a66a0cfe24b5a1036b tests/fixtures/regression-evals/deterministic-json/tests/test_normalize.py -8b06992e66ac62ecfe25d45908a0cf0c7d0777f8c5cc12ab9d0f1cb3f7236c95 tests/fixtures/regression-evals/priority-picker/README.md -0b3b076fe0bb92899828bc043e622644e7d1e291da70823227e378ab1e0dc39e tests/run_fixtures.py -611a05d753eb5442fc3d11723a5160dd3b567ffa903ca025e9a53e580bfef015 tests/test_agent_context.py -6c31e9e3c996efd24d4f69ef5dfe5391e36f7fac183c7976445716f956d081a7 tests/test_approval.py -98b9abc8dbef7620e0143bd41df0cecd45266417ea50c3ca3e99d1ce1282ccf7 tests/test_bindings.py -ee9b9a8bcd837e2a134c26926a5cd92522b031b10e8f0f6f2589b73c27f30e41 tests/test_cli_parsing.py -0e1420cd9081597fd21a7526c22004f57dd628bb7fe935718d9056b305b72e2a tests/test_config.py -2311600db15f6f01e9c702ba778c88e15547b670e64abc3c67bf47cea8479881 tests/test_contracts.py -528c476155cf5d833a7c7994c8a04328111d09f9147ff34c4048ddef6b825734 tests/test_diagnostics.py -350ef9c298776a98cbb45c86852f0251c59be690b601bc694beffec89bf7d869 tests/test_doctor.py -ec670d1a68db0f2dbfc7e55398912cba1926e4e817d5b72aed40f2538e7a985c tests/test_eval_commands.py -197a1a9b0e0f75774b7edd24be9491496a2c025f7c69a554ce29a4fa8b0a85be tests/test_eval_compare.py -dd29572b5dcd84bebcbd617608a88f6bf197b4c2bb3a0e5000cefef83f8d0316 tests/test_eval_oracles.py -d54d87416a4001ce2622fac1293c3fae1786ab66a326852795b5cd725299b966 tests/test_eval_reporting.py -f68ebeb52bdc204611f4aca5599176a88d453d08ff3047fe5c67ad8f7280ac3b tests/test_eval_runtime.py -c1f9502ec2e3131f508e1a7810eb6663e184b9bd55ac6b368c199d25efcda1cf tests/test_eval_scope.py -80bb5450f6147dbb1cbc7f5667a986a66a440b35ad6bd67c549f93e9652e9de1 tests/test_eval_scoring.py -eea780818b433f541c68b38fd428ddb5199f8c8bf8759f110f19c7cd0f7d8506 tests/test_eval_trials.py -6c970efa532bf8f466e9d80e0356d8856bdab77a7e2c3ab0f116da5efed1b9c1 tests/test_evaluation.py -6d663358943765a04c4c7fedd0f19934c619771ac2f5cdb1a238f115c2d3451c tests/test_executor_events.py -2eee31d193628d2f690ded1fd76a3aff2e9ff7847702430a36cb5b0b9a60a908 tests/test_inspect_adapter.py -30d2ad6c538a8fb84bcd7131c585072a41a84f9cc6be3b1f2fb80e13a1b8efd1 tests/test_install_uninstall.py -0a383d6b81baa18f11c93dc3ada8642fe50d88e37390fd74060cfd776c0f5f0b tests/test_ledger.py -677275ab326bbba27811df62b7301f7cb0032ee9e71f70eabe88b7fd4c093a39 tests/test_lifecycle.py -a5d4eaf6cace4f79ac486b00807ab49a2b5639267c67c7a086be146048ef7500 tests/test_manifest_validation.py -263bef2d552bbc60f22d0944588815a1782bd4f659909c3599ef3abc77a04727 tests/test_mcp_server.py -f6c1c6a323351887ad92878013bf10420e553deea763f35394ba385e44d6e14c tests/test_mcp_services.py -3692951404f51194394f4383a9122c38e90d1fec47182f49a6fedce2bf13bb4d tests/test_messages.py -e51115c54349c2dff15e59558e2b4dfde741b4b0ff3e1ad06b888ab0d3e56291 tests/test_metrics.py -bd78795706c81b1a7787c5cc89408017d5f3da9da58ce46fff6a264028e8cee8 tests/test_native_jobs.py -d8d13781ef4a508f985746f2d2ae4ff22dbffc1feb2c5088a62d6f488afb5a81 tests/test_optional_integrations.py -4735fcbdf376ac8bba98f8585eaa6d46f479d8383261554f43813fd76b0f949c tests/test_provider_evidence.py -3e3030a1afd810d9ae097fc146fd100738986152eff907d404665941cb8c7dc2 tests/test_public_eval_fixtures.py -84681c3cb4407822aea13b6bc57ba25b3d3283e30eb183814a7a460ce044b2e4 tests/test_receipts.py -fb75a2b8367059cbaf1a072fab979cb4e70c009f069a44da081efbbcddc7972b tests/test_regression_eval_fixture.py -dca5f2619e1d7c8faaad4b36fd3e19d205ca7feb2cfb1678cf6bf458af7f2f4e tests/test_release_assets.py -4d7866102e8d7763c2ea9450bfca7ccda1b1c3f11d279ee788e222bb3f5df0ef tests/test_routing.py -a03ceba145c1c37a87c47f733cae785e23a530db2c9d4d9e225d6946eb49a08c tests/test_runner_generation.py -8d50232622e7b27b68d65f37fc724f50adbb411cb1c89a91bf101ccbc9213262 tests/test_scaffold.py -365737ca68abb7b9971b1a4a15e14f5c8d1d6849d712c7f11961148099d7a702 tests/test_session_launch.py -81c0f5e333a3530d4aae5dd489f49d6779347eb087f5b5fe1d283411c11436bd tests/test_skill_contracts.py -4ad3726c148bda364ffbe670660ab7867967e07edfeb13364af342efd3281b43 tests/test_tmux.py -1112b48c728585f7206e5359e61f813f2c65fc8a333bbfdfa21e00452e4daee9 tests/test_util.py -854440f267987b4a8cff5defd0ab3fd7075300243804cbff2cb33e12c19c6612 tests/test_workflow.py -e2854d6069fd85bd18ee760f162c31e51973f47c688d4d27a692466df8cc89d2 tests/test_workflow_receipt.py -ee7ad89390cc7e1d5f52758daa529d1c61f32c79f220ffba080ba9c796de2131 tests/test_workflow_templates.py -610f1971f5fffe724f39d7f1eb098370823f1d4a611e00cce9d0bdf00c2a897b tests/test_worktree.py +aaf72fa9c81ae140ee0b3c061a2776b0994eb82ca4d844975aaea26109e1c202 tests/__init__.py +a5fd162949a559291a40fda6f4eaac67debd4bff9486b8a2518849199c781030 tests/acceptance/test_cli_product_journeys.py +c4669db368dfd6f453c7416dae541b8cd5023ca2ea1dc427f07a005ffa9f326e tests/acceptance/test_delegation_journeys.py +1e3f702f5a91135ffb27306a831acedcdc11de478bc1ef280e34ed58d9555b98 tests/acceptance/test_evaluation_journeys.py +77a21b71637a7abbbde450556f4e6b790a3e17e1504b3a91699ca04c57b4f36e tests/acceptance/test_workflow_journeys.py +3ca4d9e466d46217ab0bcbab5b780cf0f483760b52f917b11a07f11458206a4a tests/conftest.py +d53e0522fc54bf3a78e6deae839070a319d77ee2a052e3026ee5811dbce638b1 tests/future/README.md +eec8a22e2e6ac9991ea4dfd0a20da15752a5cedccb567f79c686a3973a3c7396 tests/future/test_late_steering_journey.py +38353edcfd9ba34c0e06f5b44698f85a5426b24cd97e83ad78840822e2febe3d tests/invariants/test_durable_state.py +1938fa6af7f1a7a861f4a06d5920a6120af1dbc673cec8fe982b185003b0bde2 tests/invariants/test_provider_and_evaluation.py +b26b5992bde5f7d689c17e4c9ba18a2e0062ba08903264364cb07e71af946e4d tests/invariants/test_security_boundaries.py +c2a2bbd6dca0909285d7931fea77e74eac6b95d0a7c317db18aa9c49a05dc5a3 tests/live/test_live_adapters.py +6317795ff03eb7f58893036c307f4295b46ea034161e5bffcba765ec8ba271f5 tests/release/test_distribution.py +9389c8341b49da476ef8370f852cebe42f172c06263a22d387a6cf590dd40130 tests/support.py c50a261eb8faf8b9508174bedf60cdfff24e3151fc7faf5db65345fe23bfb3e0 uninstall.sh diff --git a/Makefile b/Makefile index 90d85d9..b0b7edd 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test shell-check compile validate-example validate test: - PYTHONPATH=src python3 -m unittest discover -s tests -v + PYTHONPATH=src python3 -m pytest -q compile: python3 -m compileall -q src diff --git a/README.md b/README.md index 80b5ee5..d17bf84 100644 --- a/README.md +++ b/README.md @@ -1,284 +1,89 @@ # agent-workflow -`agent-workflow` is a terminal-first workflow for delegating bounded implementation tickets to coding agents without losing observability, source provenance, or review discipline. - -It provides: - -- one isolated Git worktree per ticket; -- one fresh, named `tmux` session per delegation; -- schema-validated, sealed prompts, commands, event streams, provenance, patches, and completion records; -- foreground, tail, inspect, interrupt, terminate, kill, and restart controls; -- durable parent/child progress, steering, acknowledgement, and blocking wait - records for active runs; -- multi-signal health diagnostics based on terminal, heartbeat, lifecycle, and log state; -- restart-safe workflow graphs with approvals, bounded result binding, deterministic templates, explainable routing, and aggregate receipts; -- sealed provider stream evidence and comparison-safe token/cost normalization; -- deterministic evaluation collectors, scorers, ledgers, comparisons, and review receipts; -- prompt-pack scaffolding, structural validation, checksums, and deterministic `.tar.zst` archives; -- reusable ticket-completion and phase-gate templates; -- skills for orchestration, prompt-pack construction, delegated implementation, and independent review; -- an optional bounded read-only local stdio MCP adapter. - -It intentionally does **not** provide automatic merging, automatic agent killing, a daemon, a web UI, remote execution, or autonomous model selection. +`agent-workflow` is a terminal-first control plane for bounded coding-agent work. It launches agents in isolated Git worktrees and tmux sessions, preserves durable evidence, supports restart-safe dependency graphs, and keeps review and acceptance under operator control. + +The project is **pre-public-release**. Core behavior is usable, but public distribution is blocked on license selection, external compatibility runs, and release-governance decisions tracked in [Public release readiness](docs/PUBLIC_RELEASE_READINESS.md). + +## What it does + +- creates and manages ticket worktrees; +- launches named Codex, Claude, or explicit commands through one execution path; +- records prompts, argv, source state, logs, structured provider events, patches, completion handoffs, and immutable receipts; +- supports status, attach, tail, interrupt, terminate, restart, review, acceptance, and rejection; +- stores durable steer, progress, acknowledgement, and watch records; +- schedules restart-safe workflow DAGs with bounded parallelism, approval gates, result bindings, retries, and aggregate receipts; +- validates and archives prompt packs deterministically; +- collects deterministic evaluation evidence and compares matched baseline/candidate cohorts; +- exposes an optional bounded read-only local stdio MCP adapter. + +It does **not** merge branches, kill suspected stalls automatically, provide a daemon or web UI, perform remote execution, or choose models autonomously. ## Requirements -- Linux or another POSIX-like environment - Python 3.11+ - Git -- `tmux` +- tmux - Bash -- Python package `jsonschema>=4.18,<5` (installed automatically by `install.sh`) -- GNU `tar` (with `--sort`, `--mtime`, and ownership-normalization options) and - `zstd` for deterministic `.tar.zst` creation +- GNU tar and zstd for deterministic `.tar.zst` archives +- a supported coding-agent executable, or an explicit command -Task manifests use a constrained YAML shape. PyYAML is used when available; a built-in parser keeps manifest parsing offline-capable. JSON Schema validation uses `jsonschema`. +Core installation includes `jsonschema`. Optional dependency groups cover evaluation, statistics, telemetry, MLflow, shell completion, and MCP. ## Install -From the extracted repository: +From a source checkout: ```bash ./install.sh -``` - -The installer installs the checkout in editable mode into the current user's -Python environment, including core dependencies and a pip-managed launcher in -`~/.local/bin`. It also installs workflow skills by symlink and creates a -starter config if one does not exist. Use `--extras eval,stats` for selected -optional dependency groups or `--extras all` for every optional group. Use -`--no-deps` only when the required dependencies are already installed; it uses -a source-link launcher instead. - -Make sure `~/.local/bin` is on `PATH`: - -```bash export PATH="$HOME/.local/bin:$PATH" -``` - -## Source checkout - -Use any normal Git checkout. The repository is the source of truth; installed command and skill links are not independent copies. - -## Planning and backlog - -[BACKLOG.md](BACKLOG.md) is the single authoritative register for unfinished, -blocked, and deferred work. Design documents retain detailed rationale and -acceptance material, but link back to the backlog rather than duplicating task -lists. - -Global instructions and the installed orchestration skill route suitable work -through this application. Host hooks are only a narrow future guardrail for -recognizable direct delegation commands; see -[global agent routing](docs/GLOBAL_AGENT_ROUTING.md). - -## First configuration - -Edit the user configuration file. Use a worktree root appropriate for the host: - -```toml -[paths] -worktree_root = "" - -[terminal] -backend = "tmux" -stall_minutes = 10 -mouse = true -orchestrator_side = "left" -max_interactive_agent_width = 2 -max_interactive_agent_vertical = 3 - -[agents] -preferred_names = ["larry", "moe", "curly"] -generated_prefix = "agent" -default_executor = "codex" -non_interactive_tmux = "dedicated_session" -default_class = "implementation" -reuse_stale_minutes = 120 - -[agents.profiles.moe] -class = "implementation" -executor = "codex" -model = "gpt-5.6-luna" - -[agents.profiles.curly] -class = "implementation" -executor = "claude" -model = "haiku" - -[agent_classes.exploratory] -interactive = false -default_executor = "claude" -default_model = "haiku" - -[agent_classes.exploratory.models] -claude = ["haiku"] -codex = ["gpt-5.6-luna"] - -[agent_classes.review] -interactive = false -default_executor = "codex" -default_model = "gpt-5.6-luna" - -[agent_classes.review.models] -claude = ["haiku", "sonnet"] -codex = ["gpt-5.6-luna", "gpt-5.6-luna"] - -[agent_classes.implementation] -interactive = true -default_executor = "codex" -default_model = "gpt-5.6-luna" - -[agent_classes.implementation.models] -claude = ["haiku", "sonnet"] -codex = ["gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-terra"] - -[executors.codex] -command = ["codex", "exec", "--sandbox", "workspace-write", "--skip-git-repo-check", "-"] -interactive_command = ["codex"] -models = ["gpt-5.6-luna", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"] -default_model = "gpt-5.6-luna" -no_go_models = ["gpt-5.6-sol", "*fast*"] -permission_args = ["--ask-for-approval", "on-request"] - -[executors.claude] -command = ["claude", "--print"] -interactive_command = ["claude"] -models = ["haiku", "sonnet", "opus", "fable"] -default_model = "sonnet" -no_go_models = ["opus", "fable"] -permission_args = ["--permission-mode", "manual"] -``` - -## Core workflow - -```bash agent-workflow doctor -agent-workflow worktree create /path/to/example P0-01 HEAD -agent-workflow launch example-p0-01 /path/to/worktrees/example/p0-01 ./phase-0/tickets/P0-01.md --ticket P0-01 --pack example-phases-0-2 --executor codex ``` -Or provide an explicit command: +The installer uses an editable local installation, links the shipped skills into supported discovery roots, and creates a starter XDG configuration without replacing unrelated files. See [Installation](docs/INSTALLATION.md) and [`config/agent-workflow.example.toml`](config/agent-workflow.example.toml). + +## First run ```bash -agent-workflow launch example-p0-01 /path/to/worktree ticket.md -- \ - codex exec --sandbox workspace-write --skip-git-repo-check - -``` +agent-workflow worktree create /path/to/repo TICKET-1 HEAD -By default, Git worktrees must be clean at launch. Use `--allow-dirty` only for an intentional continuation or recovery; retries automatically preserve and reuse the existing worktree. - -Use `--executor codex` or `--executor claude` to select a configured executor. -Use `--agent-name NAME` to request a configured preferred name. Without it, the -orchestrator assigns the first unused preferred name and then generates -`generated_prefix-NN` names after the pool is exhausted. Named profiles bind -an agent name to an executor/model pair; explicit conflicting launch options -are rejected. The assigned name is written to run evidence and shown in the -tmux pane border. -Use `--agent-class exploratory|review|implementation` to select work policy. -Classes define interactivity and allowed executor/model pairs; named profiles -may narrow a class but cannot escape it. The built-in exploratory class is -non-interactive and permits only Claude Haiku or `gpt-5.6-luna`; review is also -non-interactive; implementation is interactive by default. Additional classes -are ordinary `[agent_classes.NAME]` config tables. -Interactive agents share the orchestrator window by default. Non-interactive -assignments use an interactive executor in a detached named tmux session when -`non_interactive_tmux` is `"dedicated_session"`, so they can report completion -and close themselves without leaving a blank user-resumable session. They are -not offered as user-resumable agents; the calling agent should inspect their -durable status or delegate a new attempt. Set `non_interactive_tmux` to -`"shared_window"` only when those panes should be visible in the current -window. -Claude agents are interactive by default, including exploratory and review -classes. `--no-interactive` controls assignment visibility and reuse policy; it -does not create a blank terminal. Use `--structured` when a truly -non-interactive executor stream is required. -Use `--model MODEL`; configured defaults apply when it is omitted. Models must -be listed for that executor. A `no_go_models` match is rejected unless the run -uses `--allow-no-go-model`, which is recorded in provenance. Executor-specific -`permission_args` are always applied to configured launches: Codex uses -`--ask-for-approval` and sandbox arguments, while Claude uses -`--permission-mode` and may additionally use `--allowedTools` or -`--disallowedTools`. - -Add `--structured` to preserve raw Codex JSONL or Claude stream-JSON while rendering normalized operator output. Use `--interactive` instead to run the native executor TUI on the pane PTY; these modes are mutually exclusive. Durable `steer` records are not automatic TUI keystrokes: an interactive child must be prompted to read and acknowledge them, or an operator must send terminal input separately. Retries preserve the saved executor identity, stream format, original prompt source, and pack root. -Installed workflow skills are linked into `~/.agents/skills`, `~/.codex/skills`, -and `~/.claude/skills`. Invoke `$agent-workflow-orchestrator`, -`$prompt-pack-builder`, `$delegated-implementation`, or `$phase-gate-review` -in Codex, or use `/` instead of `$` in Claude. The installer refuses to replace -unrelated paths, so every installed name remains an unambiguous link to this -checkout. - -Observe and foreground: +agent-workflow launch \ + ticket-1 \ + /path/to/worktrees/ticket-1 \ + ./ticket.md \ + --ticket TICKET-1 \ + --executor codex -```bash -agent-workflow list -agent-workflow status example-p0-01 --capture 50 -agent-workflow attach example-p0-01 -agent-workflow tail example-p0-01 +agent-workflow status ticket-1 --capture 50 +agent-workflow attach ticket-1 ``` -Interrupt and retry without overwriting evidence: +An explicit executor command can be supplied after `--`: ```bash -agent-workflow interrupt example-p0-01 -agent-workflow restart example-p0-01 +agent-workflow launch ticket-1 /path/to/worktree ticket.md -- \ + codex exec --sandbox workspace-write --skip-git-repo-check - ``` -Exchange durable control records without polling status. A steer is a pending -request until the child explicitly acknowledges its message ID; it is not proof -that a one-shot executor has consumed a late prompt. `watch` always replays the -fsynced message log; when tmux is available it uses `tmux wait-for` only as a -best-effort local wakeup hint, so a missed hint cannot lose a control record. +Review and disposition remain explicit: ```bash -agent-workflow steer example-p0-01 "Run the focused tests before editing." --actor orchestrator -agent-workflow watch example-p0-01 --after 0 --timeout 300 -agent-workflow progress example-p0-01 "Tests are green; reviewing scope." --actor child -agent-workflow ack example-p0-01 MESSAGE_UUID "Applied at checkpoint." --actor child +agent-workflow review ticket-1 --actor reviewer --reason "evidence checked" +agent-workflow accept ticket-1 --actor reviewer --reason "criteria met" --revision SHA ``` -### Reusing an interactive agent - -Interactive agents retain bounded durable assignment context, not a raw -transcript. A child must explicitly complete its assignment before reuse: +## Durable control ```bash -agent-workflow agent task-complete SESSION --actor AGENT --summary "Implemented parser" --tag parser --file src/parser.py -agent-workflow agent candidates /path/to/worktree --ticket TICKET --pack PACK -agent-workflow agent reuse SESSION ./next-task.md --actor orchestrator --ticket TICKET --pack PACK +agent-workflow steer ticket-1 "Run the focused tests before editing." --actor orchestrator +agent-workflow watch ticket-1 --after 0 --timeout 300 +agent-workflow progress ticket-1 "Tests are green." --actor child +agent-workflow ack ticket-1 MESSAGE_ID "Applied." --actor child ``` -Candidates must be idle, live, compatible, unexpired, and in the exact same -worktree. Similar work is ranked for an operator, but automatic reuse is -restricted to exact ticket or retry lineage. Reassignment remains -`reuse_pending` until the child acknowledges the correlated steer message. -Names are globally leased across interactive panes and detached agents, so no -two active runs can use the same configured or generated agent name. - -When the interactive pane limit is full, an attached CLI prompts before doing -anything destructive: close enough explicitly idle panes, run the new job as a -detached non-interactive task, or cancel. Automation can choose explicitly with -`--pane-limit-action close-idle|non-interactive|cancel`; non-TTY callers using -the default `prompt` policy fail closed with structured choices. -The pane count and grid apply only to the exact tmux window used for the -launch. Separate tmux windows have independent limits and layouts. - -The default interactive grid creates two agent columns to the right of the -orchestrator before adding vertical splits. It then balances agents across the -two columns, with at most three agents per column (six total). - -## Structured task results and dependency graphs - -Prompt-pack task dependencies are validated as a cross-phase DAG. Tickets may optionally declare a JSON Schema result contract; validated `result.json` artifacts and collection receipts are copied into and sealed with the durable run. See [Workflow Foundations Plan](docs/WORKFLOW_FOUNDATIONS_PLAN.md). +The append-only message log is authoritative. tmux wakeups are only best-effort hints. A steer remains pending until the child emits correlated acknowledgement evidence; the current detached-executor late-steering gap is tracked in [BACKLOG.md](BACKLOG.md). ## Workflow graphs -Workflows use a canonical normalized snapshot plus an append-only event journal. -They reuse the existing session launch service and never create a second executor -path. Approval gates validate immutable lifecycle receipt chains; input bindings -copy bounded JSON Pointer values from sealed ancestor results; terminal workflows -can be sealed into an aggregate receipt. - ```bash agent-workflow workflow validate ./workflow.json agent-workflow workflow start ./workflow-run ./workflow.json @@ -288,88 +93,74 @@ agent-workflow workflow seal ./workflow-run ./workflow.json agent-workflow workflow verify ./workflow-run ./workflow.json ``` -Generate one of the three authorized graph shapes with `workflow template`: +Workflow state is reconstructed from an immutable normalized snapshot and append-only event journal. Child sessions use the normal launch service. Approval nodes rely on canonical lifecycle receipts, and result bindings copy bounded JSON Pointer values from sealed predecessor results. + +Authorized templates: ```bash -agent-workflow workflow template pipeline ./pipeline-spec.json --output ./workflow.json -agent-workflow workflow template parallel-review-fan-in ./review-spec.json --output ./workflow.json -agent-workflow workflow template implementation-independent-review ./implementation-review-spec.json --output ./workflow.json +agent-workflow workflow template pipeline ./spec.json --output ./workflow.json +agent-workflow workflow template parallel-review-fan-in ./spec.json --output ./workflow.json +agent-workflow workflow template implementation-independent-review ./spec.json --output ./workflow.json ``` -After `start`, later commands must supply the same snapshot digest. Workflow -status files are projections; the snapshot, event journal, child seals, approval -receipts, and aggregate workflow receipt are the evidence authorities. - ## Prompt packs ```bash -agent-workflow pack scaffold ./my-project-prompt-pack --phases 3 -agent-workflow pack validate ./my-project-prompt-pack -agent-workflow pack archive ./my-project-prompt-pack ./my-project-prompt-pack.tar.zst +agent-workflow pack scaffold ./my-pack --phases 3 +agent-workflow pack validate ./my-pack +agent-workflow pack archive ./my-pack ./my-pack.tar.zst ``` -## Deterministic evaluation +Prompt-pack dependencies form a validated cross-phase DAG. Tickets may declare JSON Schema result contracts whose validated handoffs are sealed with run evidence. See [Prompt packs](docs/PROMPT_PACKS.md). + +## Evaluation ```bash -agent-workflow eval validate ./evals/evaluation.json --pack ./prompt-pack -agent-workflow launch eval-p0-01 /path/to/worktree ticket.md \ - --ticket P0-01 --executor codex --structured \ - --evaluation ./evals/evaluation.json -agent-workflow eval score eval-p0-01 -agent-workflow eval report eval-p0-01 --format markdown -agent-workflow ledger ./prompt-pack -agent-workflow review eval-p0-01 --actor reviewer --reason "gates checked" -agent-workflow accept eval-p0-01 --actor reviewer --reason "approved" --revision SHA +agent-workflow eval validate ./evaluation.json --pack ./prompt-pack +agent-workflow eval score SESSION +agent-workflow eval report SESSION --format markdown +agent-workflow eval compare ./baseline.json ./candidate.json --output ./comparison.json ``` -Baseline commands and scope are captured before the agent; post scope is captured before post commands. Collector artifacts are sealed before scoring. Structured executor streams are bounded and normalized into sealed provider evidence with explicit delta/cumulative/terminal semantics. Incomplete or malformed usage evidence is rejected by trial collection, and provider-billed versus locally estimated cost remains separate. Evaluator-only oracle material remains outside the checkout and is addressed by ID and SHA-256. +Raw provider streams are bounded and sealed before normalization. Usage evidence distinguishes delta, cumulative, and terminal totals and never mixes provider-billed cost with local estimates. Cohort comparison requires matched task identities and remains descriptive when samples are too small. See [Evidence and evaluation](docs/EVIDENCE_AND_EVALUATION.md). + +## Optional MCP server -Inspect AI, statistics, OpenTelemetry, MLflow, and generated shell completions are optional extras. Their adapters are intentionally experimental seams: the Inspect seam reuses the public `inspect_swe` Codex and Claude agents inside an Inspect-owned Docker sandbox, while paid model trials and external backend/harness validation remain operator-run gates. +Install the `mcp` extra and configure `agent-workflow-mcp` as a local stdio server. The current adapter is read-only and bounded to configured roots. It does not expose launch, workflow mutation, review, destructive lifecycle commands, raw shell, arbitrary paths, terminal capture, or HTTP. See [MCP server](docs/MCP_SERVER.md). -## State and evidence +## State and trust -Authoritative records are stored under: +Authoritative run evidence is stored below the configured XDG state root, normally: ```text ~/.local/state/agent-workflow/runs// ``` -Each worktree receives a discoverability symlink at `.delegations/`. Deleting a worktree therefore does not delete the authoritative evidence bundle. `final-receipt.json` hashes every required artifact; `events.jsonl` and immutable review receipts record later lifecycle actions without rewriting sealed agent evidence. - -## Optional MCP server +Worktree `.delegations/` entries are discoverability links, not evidence authorities. Status files and terminal captures are projections. Sealed receipts, lifecycle records, workflow snapshots, workflow journals, and verified child evidence determine state transitions. See [Architecture](docs/ARCHITECTURE.md) and [Security](SECURITY.md). -Install the `mcp` extra and run `agent-workflow-mcp` from an MCP host that supports -local stdio servers. The current adapter exposes bounded read-only run, status, -message, and receipt resources plus pack validation. It does not expose launch, -workflow mutation, review/acceptance, raw shell, arbitrary paths, terminal -capture, or HTTP. See [MCP Server Implementation Proposal](docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md) for the separately gated mutation plan. +## Development and testing -## Compatibility scripts +```bash +python -m pip install -e '.[dev]' +pytest +./scripts/release-check.sh +``` -The `scripts/` directory preserves the original helper filenames as thin wrappers around the CLI. Lifecycle behavior belongs only in `src/agent_workflow/`. +The default suite is acceptance-first: it builds and installs a wheel, invokes public executables as subprocesses, and exercises real Git/filesystem/process journeys. A compact invariant layer protects security, replay, and accounting boundaries. Strict expected-failure future journeys keep approved TDD work visible, and live tmux/provider checks remain opt-in. See [Testing](docs/TESTING.md). ## Documentation -- `EXECUTION_PROTOCOL.md` -- `DELEGATION_RUNBOOK.md` -- `docs/PROMPT_PACK_STANDARD.md` -- `docs/ARCHITECTURE.md` -- `docs/diagrams/REPOSITORY_CHART_PACK.md` -- `docs/PROVIDER_EVIDENCE_RESEARCH.md` -- `docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md` -- `docs/MCP_TOOL_AND_RESOURCE_CATALOG.md` -- `docs/MCP_THREAT_MODEL.md` -- `docs/MODEL_TIERS.md` -- `docs/TEST_POLICY.md` -- `docs/STALL_RECOVERY.md` -- `docs/MIGRATING_EXISTING_ASSETS.md` -- `CLEANUP_AND_REMOVAL_AUDIT.md` -- `SECURITY.md` - -## Development validation - -```bash -PYTHONPATH=src python3 -m pytest -q -python3 -m compileall -q src -./scripts/release-check.sh -``` +- [Command reference](docs/COMMAND_REFERENCE.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Repository diagrams](docs/diagrams/REPOSITORY_CHART_PACK.md) +- [Operations](docs/OPERATIONS.md) +- [Prompt packs](docs/PROMPT_PACKS.md) +- [Evidence and evaluation](docs/EVIDENCE_AND_EVALUATION.md) +- [Testing](docs/TESTING.md) +- [MCP server](docs/MCP_SERVER.md) +- [Public release readiness](docs/PUBLIC_RELEASE_READINESS.md) +- [Backlog](BACKLOG.md) +- [Contributing](CONTRIBUTING.md) +- [Support](SUPPORT.md) + +The repository is the source of truth. Completed implementation prompt packs, release-run ledgers, and one-off audit reports are intentionally not retained as parallel documentation; Git history and the changelog preserve that history. diff --git a/REMOVED_PATHS.txt b/REMOVED_PATHS.txt deleted file mode 100644 index e665998..0000000 --- a/REMOVED_PATHS.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Removed by the 0.2.0 workflow-completion change set. -src/agent_workflow/mcp/SDK_SNAPSHOT.md -src/agent_workflow/mcp/sdk/ - -# Generated paths removed before packaging (not source changes). -.pytest_cache/ -**/__pycache__/ -**/*.pyc -build/ -dist/ -*.egg-info/ diff --git a/SECURITY.md b/SECURITY.md index d93ec1b..330339c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,37 +1,40 @@ -# Security and Trust Boundaries +# Security policy -`agent-workflow` executes operator-selected coding agents and arbitrary explicit commands. Treat every delegated command, prompt pack, target repository, and generated patch as untrusted until reviewed. +`agent-workflow` executes operator-selected coding agents and explicit commands against source repositories. Treat prompts, prompt packs, delegated processes, provider output, generated patches, and target repositories as untrusted until reviewed. -## Guarantees in v0.1 +## Supported status -- Commands are stored as argv arrays and rendered with shell-safe quoting. -- Session, ticket, and pack identifiers are restricted to safe filesystem and tmux characters. -- Prompts are copied into durable state and hashed before execution. -- Source revision, branch, and dirty state are recorded before launch. -- Status files are updated atomically. -- A delegation receives a fresh named tmux session; existing session and run-state names are rejected. -- Interrupt, terminate, kill, and restart preserve prior logs and evidence. -- The workflow never merges branches, deletes failed worktrees automatically, or kills a merely suspected stalled process. +The project is pre-public-release and does not yet have a monitored public vulnerability-reporting channel. This is a release blocker tracked as `REL-002` in [BACKLOG.md](BACKLOG.md). Do not publish sensitive reports in a public issue tracker. -## Operator responsibilities +## Security boundaries + +- Commands are preserved as argv arrays and rendered with shell-safe quoting. +- IDs are restricted before they become paths, tmux names, or evidence keys. +- Configured roots are resolved and traversal/symlink escapes are rejected. +- Prompts, source state, commands, streams, artifacts, and receipts are hashed. +- Status files, logs, and terminal capture are projections, not authorities. +- Final, lifecycle, score, and workflow receipts are regular read-only files verified through stable descriptors. +- Workflow state is reconstructed from an immutable snapshot and append-only journal. +- Approval is explicit and binds actor, reason, revision, and sealed evidence. +- Provider usage fails closed on mixed modes, conflicting identities, malformed totals, or incomplete cost metadata. +- MCP is optional, local stdio, bounded to configured roots, and currently read-only. +- The project does not automatically merge, delete failed worktrees, terminate suspected stalls, expose remote execution, or authorize network MCP transport. -- Review prompt packs before execution. -- Run agents with the least filesystem and network access appropriate to the ticket. -- Keep credentials out of prompts, logs, repositories, and command arguments. -- Inspect diffs before running project tests or merging. -- Treat model-generated shell commands, dependency changes, and network calls as untrusted. -- Use separate operating-system accounts or containers for higher-risk repositories. +## Operator responsibilities -## State sensitivity +- Run agents with the least filesystem, network, and tool access needed. +- Keep credentials and private data out of prompts, argv, logs, repositories, and state bundles. +- Review patches and evidence before executing project code or accepting a run. +- Use separate operating-system accounts, containers, or disposable hosts for higher-risk targets. +- Protect the XDG state directory; it can contain source paths, prompts, model output, provider streams, and code fragments. +- Do not publish a receipt bundle without reviewing every sealed artifact it references. -The state directory can contain source paths, prompts, model output, and code fragments. Its default location is: +The default state location is: ```text ~/.local/state/agent-workflow ``` -Protect it with normal user-only filesystem permissions and do not publish it as part of a source repository. - -## Reporting +## Reporting before public release -This initial package does not prescribe a public vulnerability-reporting address. Add one before publishing the repository beyond trusted collaborators. +Trusted collaborators should contact the maintainer through an existing private channel and provide the smallest safe reproduction. Include version, platform, command category, and whether the issue affects path containment, evidence authority, process control, or provider accounting. Do not include secrets or private state bundles unless a secure transfer path has been agreed. diff --git a/SESSION_RESTORE.md b/SESSION_RESTORE.md deleted file mode 100644 index 1f8b2cb..0000000 --- a/SESSION_RESTORE.md +++ /dev/null @@ -1,54 +0,0 @@ -# Session restore checkpoint - -**Release:** 0.2.1 -**Branch:** `feature/workflow-prep-for-mcp` -**Checkpoint date:** 2026-07-24 - -## Current implementation state - -- Workflow foundation tickets WF-00, WF-01, WF-02, WF-10, WF-11, WF-12, WF-20, WF-21, and WF-22 are implemented in the current working tree. -- Receipt-backed approval gates reconstruct canonical lifecycle evidence rather than trusting mutable status pointers. -- Result bindings use bounded RFC 6901 JSON Pointers over sealed ancestor results; parent snapshots and child `workflow-inputs.json` are atomically installed read-only before executor launch. -- Terminal workflows can be sealed and verified through aggregate workflow receipts. -- The three authorized templates and deterministic routing advisor are implemented. -- BKL-003 provider evidence and BKL-005 trial evidence are implemented with 0.2.1 fail-closed stream and scorer-receipt validation; BKL-004 real paid executor cohorts remain open. -- MCP-001 and MCP-002 remain implemented as a bounded read-only local stdio adapter. WF-22 is complete, so MCP-003 is now ready but not implemented. -- The unused vendored MCP SDK source tree has been removed. The optional pinned dependency is recorded in `src/agent_workflow/mcp/SDK_DEPENDENCY.md`. - -## Durable authorities - -- Per-run evidence lives below the configured XDG state root, normally `~/.local/state/agent-workflow/runs//`. -- Workflow state is reconstructed from `workflow-snapshot.json` and `workflow-events.jsonl`. -- The canonical snapshot is read-only and descriptor-validated; scheduler mutation, sealing, verification, and projection refresh share `workflow.lock`. -- `status.json`, `workflow-status.json`, logs, and tmux capture are projections/observations. -- `final-receipt.json`, lifecycle receipts, and `workflow-receipt.json` are read-only canonical seals; final-run verification returns the digest of the same receipt bytes read under `seal.lock`, workflow receipts use one stable descriptor under `workflow.lock`, and lifecycle receipt roots may not be symlinks. -- Content-addressed scorer receipts are regular, non-symlink, read-only files; lifecycle review binds the exact score-set bytes it validated. -- Authority-bearing sealed JSON is read through beneath-root descriptors and matched to its final-receipt size/hash; read-only enforcement covers required and optional artifact trees without following symlinks. - -## Resume validation - -From the repository root: - -```bash -PYTHONPATH=src python3 -m pytest -q -python3 -m compileall -q src -bash -n install.sh uninstall.sh scripts/*.sh -python3 scripts/audit-release-assets.py -PYTHONPATH=src python3 -m agent_workflow.cli pack validate prompt-packs/chatgpt-workflow-completion-next -``` - -Regenerate the release manifest after any tracked content change: - -```bash -python3 scripts/audit-release-assets.py --write-manifest -``` - -## Primary references - -- `BACKLOG.md` -- `FEATURE_TEST_LEDGER.md` -- `docs/ARCHITECTURE.md` -- `docs/diagrams/REPOSITORY_CHART_PACK.md` -- `docs/PROVIDER_EVIDENCE_RESEARCH.md` -- `docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md` -- `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md` diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..99ba1c0 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,15 @@ +# Support + +`agent-workflow` is currently a pre-public-release project without a guaranteed support service level. + +For trusted-collaborator use: + +1. run `agent-workflow doctor` and retain its output; +2. capture the command, public error output, platform, Python, Git, and tmux versions; +3. identify the affected session or workflow without publishing private prompts or state artifacts; +4. reproduce with the smallest safe repository or prompt pack; +5. check [BACKLOG.md](BACKLOG.md) and [docs/OPERATIONS.md](docs/OPERATIONS.md). + +Do not publish XDG state directories, prompts, source fragments, provider streams, credentials, or sealed evidence bundles without reviewing their contents. + +A public issue tracker and monitored vulnerability-reporting channel must be established before the first supported public release. Security reports should follow [SECURITY.md](SECURITY.md). diff --git a/VERSION b/VERSION index 0c62199..ee1372d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/agent-workflow.yaml b/agent-workflow.yaml index 79455db..77a2be8 100644 --- a/agent-workflow.yaml +++ b/agent-workflow.yaml @@ -1,6 +1,6 @@ schema: agent-workflow/repository/v1 name: agent-workflow -version: 0.2.1 +version: 0.2.2 terminal_backend: tmux python: ">=3.11" state_model: xdg-json-files diff --git a/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md b/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md deleted file mode 100644 index d072ed3..0000000 --- a/docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md +++ /dev/null @@ -1,125 +0,0 @@ -# P0: Agent-Workflow Skill and Orchestrator Integration - -**Status:** Implemented. This document preserves the original P0 acceptance contract; current behavior is summarized below and tracked in `BACKLOG.md`. - -## Current outcome - -The repository now installs and cross-links the orchestration, delegated-implementation, phase-gate-review, and prompt-pack-builder skills across the supported shared, Codex, and Claude discovery roots. Those skills name the canonical CLI lifecycle, preserve the distinction between host-native subagents and durable `agent-workflow` runs, and document the tmux current-window/detached fallback behavior. Installer ownership checks remain fail-closed for unrelated paths. - -## Problem - -`agent-workflow` has a complete CLI, durable run evidence, lifecycle runbooks, -and tmux behavior, but agents are not given an operational path to use them. -The installed skills describe ticket implementation, pack construction, and -phase review without naming the executable, command sequence, trigger rules, -or the boundary between native subagents and an `agent-workflow` run. - -Consequently, an agent can read documentation yet still delegate through a -host-native subagent API, bypassing worktrees, durable receipts, control -records, and visible tmux panes. The current same-window tmux feature only -applies after an actor invokes `agent-workflow launch`; it cannot intercept a -native subagent spawn. - -## Required outcome - -Create one discoverable orchestration skill and make existing workflow skills -invoke it consistently. An agent must be able to answer, without guessing: - -1. whether this work should be a direct edit, native subagent, or durable - `agent-workflow` delegation; -2. how to create/validate a pack, worktree, and named run; -3. how to observe, steer, acknowledge, interrupt, review, and accept the run; -4. when a tmux pane will be visible; and -5. what `steer` does not prove for one-shot executor stdin. - -## Scope - -### New orchestration skill - -Add a repo-owned `agent-workflow-orchestrator` skill with a precise discovery -description and this decision table: - -| Situation | Required action | -|---|---| -| Small, local, one-step change with no independent evidence need | Work directly; do not create ceremonial delegation. | -| Bounded implementation ticket requiring an isolated worktree, persistent evidence, review, or recovery | Use `agent-workflow` and a validated prompt pack. | -| Read-only focused investigation where host-native subagents are explicitly preferred | Use the host-native mechanism; state that it is not an `agent-workflow` run. | -| Work requiring user-visible child panes while the orchestrator is inside tmux | Invoke `agent-workflow launch`; do not hand-create tmux sessions. | -| Need to alter a running agent | Append a durable `steer`; do not claim delivery until the executor adapter produces a correlated acknowledgement. | - -The skill must give exact command patterns for `doctor`, `pack validate`, -`worktree create`, `launch`, `status`, `watch`, `steer`, `progress`, `ack`, -`interrupt`, `terminate`, `review`, and `accept`. It should link rather than -copy the detailed [command reference](COMMAND_REFERENCE.md), -[delegation lifecycle](DELEGATION_LIFECYCLE.md), and runbook/protocol. - -### Update existing skills - -- `prompt-pack-builder`: state when a pack is required and require its - generated README/runbook to name `agent-workflow` invocation and the tmux - visibility rule. -- `delegated-implementation`: state that the ticket should already be launched - through the CLI, require durable `progress`/`ack` where the executor adapter - supports them, and prohibit spawning a replacement agent from inside a - ticket session. -- `phase-gate-review`: require evidence inspection through the CLI and - distinguish sealed receipts from terminal output. - -### Installation and discovery - -Make the installer expose the complete skill set to every supported discovery -root, based on an explicit, tested policy. Do not assume that a host-native -skill discovery root and `~/.agents/skills` are equivalent. Verify the actual -Codex, Claude, and shared-agent roots on the target host; avoid duplicate-name -conflicts and preserve unrelated user-owned paths. - -## tmux and native-agent contract - -`agent-workflow launch` resolves a valid current tmux window and splits a -visible pane there. With no usable tmux context it retains the named detached -session fallback. Skills must direct agents to this command, never to raw -`tmux new-session`/`split-window` calls. - -Host-native agent APIs are an independent control plane. They are not made -durable or visible by this repository unless a future host integration invokes -the CLI and translates lifecycle events. Do not imply that this P0 task solves -that integration automatically. - -## Acceptance criteria - -- A fresh supported agent can discover the orchestration skill and choose the - CLI for a qualifying bounded delegation. -- The skill gives an executable happy-path command sequence and a concise - recovery/control sequence. -- It states the tmux current-window behavior and detached fallback accurately. -- Existing skills cross-link consistently and do not duplicate conflicting - policy. -- Installer tests cover every intended skill root, idempotence, and refusal to - replace an unrelated file/symlink. -- Documentation tests verify the canonical links/commands; full release checks - pass. - -## Explicit non-goals - -- Do not make a generic host-native `spawn_agent` API transparently become an - `agent-workflow` run. -- Do not add terminal keystroke injection as a steering transport. -- Do not add MCP transport, a daemon, a broker, or a database in this ticket. -- Do not claim that an acknowledgement exists when the executor cannot receive - late semantic input. - -## References - -- [Command reference](COMMAND_REFERENCE.md) -- [Delegation lifecycle](DELEGATION_LIFECYCLE.md) -- [Prompt-pack standard](PROMPT_PACK_STANDARD.md) -- [Current durable-control/evidence research](Durable_Orchestration_Delivery_Benchmarks.md) -- [Canonical backlog](../BACKLOG.md) - -## Implemented discovery policy - -The installer links all four repo-owned workflow skills into the shared agent -root (`~/.agents/skills`), Codex root (`~/.codex/skills`), and Claude root -(`~/.claude/skills`). Installation is idempotent only for links owned by this -checkout. Any unrelated file or symlink at a target name is preserved and the -installer exits with an error rather than introducing divergent skill content. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a08adf1..d988955 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,6 +39,7 @@ target repository/worktree | Prompt packs | `pack.py`, `manifests.py`, `native_jobs.py`, `contracts.py` | scaffold, DAG validation, checksums/archive, structured results | | Evaluation | `evaluation.py`, `eval/*`, `inspect_adapter.py`, `integrations/*` | collectors, scoring, immutable trials, cohort comparison, optional adapters | | MCP | `mcp/server.py`, `mcp/services.py` | optional bounded read-only stdio adapter over shared read services | +| Tests | `tests/acceptance`, `tests/invariants`, `tests/future`, `tests/live`, `tests/release` | installed-product journeys, compact authority matrices, executable future specifications, live compatibility, and distribution checks | ## Runtime state @@ -129,7 +130,7 @@ Raw executor events must be stable regular non-symlink files, are capped at 16 M The current MCP server is optional, local stdio, and read-only except for pack validation. It uses the public pinned `mcp` package and bounded shared read services. It exposes no launch, workflow mutation, lifecycle mutation, raw shell, tmux, arbitrary paths, terminal capture, or HTTP transport. -The planned mutation design is in [MCP Server Implementation Proposal](MCP_SERVER_IMPLEMENTATION_PROPOSAL.md). Future tools must add durable idempotency and call existing services. Streamable HTTP requires a separate authorization ADR. +The current and planned boundary is in [MCP server](MCP_SERVER.md). Future tools must add durable idempotency and call existing services. Streamable HTTP requires a separate authorization ADR. ## Security posture @@ -143,8 +144,12 @@ The planned mutation design is in [MCP Server Implementation Proposal](MCP_SERVE - external evaluator/oracle material stays outside delegated worktrees; - logs and terminal capture never grant approval or prove message delivery. -See [Security Trust Boundaries](SECURITY_TRUST_BOUNDARIES.md) and [MCP Threat Model](MCP_THREAT_MODEL.md). +See [Security](../SECURITY.md) and [MCP server](MCP_SERVER.md). -## Release and packaging +## Testing, release, and packaging + +The default test authority is the installed product, not private Python helpers. Acceptance tests build a wheel, install it into an isolated virtual environment, and invoke public executables across real process, Git, and filesystem boundaries. A compact invariant layer covers security, replay, scheduler, provider-accounting, and cohort rules that need exhaustive matrices. Strict future tests describe approved backlog outcomes; live tmux/provider compatibility is opt-in. See [Testing](TESTING.md). Release assets are checked by `scripts/audit-release-assets.py`; `MANIFEST.sha256` must cover every distributable file. The release gate runs tests, compile checks, shell syntax checks, prompt-pack validation, manifest verification, and deterministic archive tooling checks. Third-party MCP SDK source is not vendored or packaged; only the pinned optional dependency and dependency record remain. + +The repository is pre-public-release. License, reporting, supported-host, and release-ownership decisions remain blockers in [Public release readiness](PUBLIC_RELEASE_READINESS.md). diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index de8d936..0cc68ee 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -39,7 +39,7 @@ agent-workflow kill SESSION agent-workflow restart SESSION [--new-session NAME] ``` -Configured launches enforce class/executor/model allowlists and permission arguments. A no-go model requires `--allow-no-go-model`, which is recorded. `--structured` and native interactive TUI mode are mutually exclusive. Git worktrees must be clean unless `--allow-dirty` is explicit; retries preserve prior evidence and lineage. A non-interactive assignment is not user-resumable: by default it runs an interactive executor in a private tmux session, reports through durable handoff records, and closes that session on exit when possible. Pane limits and layout apply only to the exact tmux window containing a visible launch; separate windows have independent capacity. +Configured launches enforce class/executor/model allowlists and permission arguments. A no-go model requires `--allow-no-go-model`, which is recorded. `--structured` and native interactive TUI mode are mutually exclusive. Git worktrees must be clean unless `--allow-dirty` is explicit; retries preserve prior evidence and lineage. ## Durable messages @@ -99,7 +99,7 @@ agent-workflow eval validate PLAN [--pack PACK] agent-workflow eval score RUN [--output-dir PATH] [--oracle-root PATH] agent-workflow eval report RUN [--format json|markdown] [--output PATH] agent-workflow eval collect RUN... --output TRIALS.json -agent-workflow eval compare BASELINE.json CANDIDATE.json [--output PATH] +agent-workflow eval compare BASELINE.json CANDIDATE.json --output PATH agent-workflow eval inspect PROMPT --executor codex|claude --model MODEL --dockerfile FILE --log-dir DIR agent-workflow eval swebench-prediction RUN --instance-id ID --model MODEL --output FILE.jsonl agent-workflow ledger PACK [--runs-root PATH] [--output PATH] diff --git a/docs/DELEGATION_LIFECYCLE.md b/docs/DELEGATION_LIFECYCLE.md deleted file mode 100644 index 4d1c315..0000000 --- a/docs/DELEGATION_LIFECYCLE.md +++ /dev/null @@ -1,31 +0,0 @@ -# Delegation Lifecycle - -## Prepare - -- validate the pack; -- confirm ticket dependencies; -- create a clean isolated worktree; -- choose a unique session name; -- capture source revision and branch. - -## Launch - -The immutable prompt copy, command argv, prompt hash, and source baseline are stored before tmux starts. A failed launch is recorded rather than silently discarded. - -## Observe - -Use `status`, `attach`, and `tail`. The operator can always bring the terminal to the foreground. A likely stall is an observation requiring human classification. - -## Interrupt and retry - -Interrupt first. Preserve the original run directory and worktree. A retry uses `-retryN`, copies the original prompt and command, and links back through `retry_of`. - -## Complete - -The implementer fills in the generated completion report. The reviewer inspects the diff and reruns narrow gates independently. The original session record remains evidence after branch merge or worktree removal. - -For an interactive agent that remains open, process lifetime and assignment -completion are separate. The child emits `agent task-complete` with a bounded -summary, tags, and touched files; no terminal text is inferred. A new -assignment is `reuse_pending` until its correlated acknowledgement. Automatic -reuse is limited to exact ticket/retry lineage in the same worktree. diff --git a/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md b/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md deleted file mode 100644 index bd0b329..0000000 --- a/docs/DURABLE_WAKEUP_AND_EVIDENCE_COMPLETION_PLAN.md +++ /dev/null @@ -1,163 +0,0 @@ -# Durable Wakeup and Evidence Completion Plan - -**Status:** Phases A-C are implemented. Active, blocked, and deferred follow-on -work is tracked only in [BACKLOG.md](../BACKLOG.md); this document preserves -the implementation contract and acceptance history. - -## Historical purpose and current outcome - -This document preserves the preimplementation contract that completed two formerly missing foundations for observable, restart-safe orchestration: - -1. accelerate durable control-record delivery when parent and child share a tmux server, without making tmux authoritative; and -2. turn sealed execution metrics into comparable, provenance-backed token, cost, and timing evidence. - -The deterministic implementation is complete: tmux wakeups remain best-effort accelerators over replayable JSONL, bounded raw provider streams are sealed before normalization, provider adapters preserve explicit usage modes, and trial evidence can be persisted and compared. BKL-003 and BKL-005 are complete. The real-executor cohort remains the separately controlled BKL-004 backlog item. - -## Historical starting point - -At the time this plan was written, the append-only `messages.jsonl` protocol was already authoritative and restart-safe, while `watch` relied only on replay polling. Execution metrics contained nullable normalized usage fields and elapsed time, but the runner retained only the latest executor usage event and the evaluation CLI could not persist or compare a sealed-trial cohort. This paragraph is historical context, not the current implementation state. - -## Non-negotiable contracts - -| Contract | Required behavior | -|---|---| -| Durable-first | A message is visible on replay before any wake signal is sent. A failed signal must never change a successful append into an error. | -| Replay authority | A receiver replays before waiting and after every wake. A wake may be lost, stale, duplicated, or unavailable without losing a record. | -| Bounded waiting | A `tmux wait-for` client is bounded by the caller deadline. Timeout, absent tmux, absent server, or nonzero exit returns to ordinary bounded replay polling. | -| No terminal injection | Mid-task executor steering remains adapter work. This plan does not use `send-keys` or infer state from terminal text. | -| Usage semantics | Accumulate only explicitly labeled provider deltas. A later cumulative/terminal total replaces the corresponding accumulated dimension; do not guess and double-count. | -| Unknown is null | Missing child usage, provider cost, or stage duration stays `null`, never zero or derived from prose. | -| Sealed inputs | Benchmark trials are extracted only from sealed receipts and written to explicit baseline/candidate evidence files. Comparison never mutates a sealed run or auto-selects a baseline. | - -## Model routing and work split - -Use Lina for the wakeup implementation and GPT-5.4-mini for evidence implementation, with mini agents only for read-only research and independent checks. In this environment those requested model labels are not exposed; use the smallest available worker for the equivalent bounded task and preserve the same ownership boundaries. - -| Phase | Primary owner | Independent verifier | Scope | -|---|---|---|---| -| A | Lina-equivalent implementation agent | mini research/verifier | tmux wakeup accelerator | -| B | GPT-5.4-mini-equivalent implementation agent | mini verifier | usage accumulation and stage timing | -| C | GPT-5.4-mini-equivalent implementation agent | mini verifier | immutable trial evidence and CLI comparison | -| D | operator plus mini analyst | phase gate reviewer | real-executor cohort and baseline decision | - -## Phase A — Durable Control Record wakeup accelerator - -### A1. Add a narrow tmux wake adapter - -Change `src/agent_workflow/tmux.py` only to add a deterministic channel function plus best-effort signal/wait functions. The channel must be a versioned SHA-256 digest of `run_dir.resolve()` (for example `agent-workflow/v1/`), not a raw session identifier. `signal_waiters(channel)` invokes `tmux wait-for -S CHANNEL` and suppresses unavailable tmux/server/nonzero failures. `wait_for_wakeup(channel, timeout_seconds)` runs `tmux wait-for CHANNEL`, enforces its own timeout, reaps the child process, returns `False` for unavailable/error/timeout, and preserves `KeyboardInterrupt` after cleanup. - -`tmux wait-for` is a hint only. Do not depend on retained signals or on a tmux socket being available in tests. - -### A2. Notify only after the durable commit - -Add an injectable `after_commit` hook to `append_message` in `src/agent_workflow/messages.py`. Invoke it after append, flush, fsync, close, and lock release. Suppress callback errors so the successful returned record remains successful. In `src/agent_workflow/sessions.py`, route `steer`, `progress`, and `acknowledge` through one helper that appends and then calls the tmux signal function for that run directory. - -Direct callers of `append_message` remain durable without automatically waking; document that boundary in code/tests. - -### A3. Make waiting replay-first and bounded - -Extend `wait_for_messages` with injectable wake/channel seams rather than coupling `messages.py` directly to tmux. Preserve existing polling as the default. `wait_for_message` supplies the run’s channel and wait function. Each loop must replay first, return contiguous records if present, calculate a monotonic remaining deadline, wait for at most `min(poll_seconds, remaining)`, and replay again. If wake fails or is unavailable, sleep only when the waiter did not consume the time slice. - -This intentionally tolerates a record arriving between replay and wait registration: the next bounded replay finds it even if the signal was lost. - -### A4. Acceptance tests - -- Failed notifier does not fail or corrupt a durable append; it runs only after the record is observable. -- Existing replay returns immediately without invoking wake. -- Signal then append, stale/spurious signals, and a dropped-signal replay/wait race all return only durable records and preserve ordering. -- `timeout=0`, finite timeout, unavailable tmux, waiter error, and cancellation are bounded and clean up the process. -- Channels are deterministic per resolved run directory and differ across directories. -- Session steer/progress/ack wrappers signal the matching run; README accurately calls `watch` durable replay with a best-effort tmux wakeup. - -### A5. Visible child panes when launched from tmux - -When the launcher process has a valid `TMUX` environment and can resolve its current tmux session/window, create the child with `split-window` in that exact window rather than a detached fresh session. Record the resulting pane target/ID in the same durable launch metadata already used for tmux identification. Do not parse untrusted shell output, infer a target from a pane title, or attach elsewhere. If the environment is absent, stale, or unresolvable, retain the present detached named-session path; do not silently launch a pane into an arbitrary server/window. - -Tests must mock current-client discovery and assert the exact target is used, pane metadata is persisted, and every unavailable/stale-environment path falls back to the existing named-session behavior. This is a local operator-visibility feature, not a cross-host transport or a steering mechanism. - -## Phase B — Provider-neutral token, cost, and timing evidence - -### B1. Create an explicit usage accumulator - -Add a small pure helper near `event_usage`/metrics that consumes normalized provider events with one declared mode: `delta`, `cumulative`, or `terminal`. It should carry input, cached-input, output, total, cost, and currency independently. Sum explicitly-delta numeric fields; a cumulative/terminal value is authoritative for that field and replaces an earlier accumulated value. Reject booleans, negatives, and malformed numbers. Never infer a mode from field shape. - -Wire the runner to retain the final accumulated snapshot in provenance and in budget checks rather than retaining only the last structured event. Keep legacy events compatible by treating a plain final usage object as one terminal snapshot when it is emitted at terminal completion; document the boundary and test it. - -### B2. Normalize common cached-token layouts - -Extend `normalize_usage` to support known aliases without manufacturing data: `cached_input_tokens`, `cache_read_input_tokens`, top-level `cached_tokens`, and OpenAI-style `prompt_tokens_details.cached_tokens`. Preserve `null` for unknown values. Preserve provider reported total/cost/currency only when valid. - -### B3. Populate verifier command timing - -Read the sealed `collections/commands-post.json` content when constructing execution metrics. Report a verification-stage duration derived from valid command durations. Keep overall elapsed time as run wall time, never the sum of stages. Verifier usage/cost and child-stage usage/time remain null unless a future sealed receipt explicitly supplies them. - -### B4. Acceptance tests - -- delta input/output aggregation, then a terminal cumulative replacement, cannot double-count; -- nested cached-token aliases normalize correctly and malformed/bool/negative values remain null; -- partial usage stays partial rather than becoming zero; -- command post-collection produces verification duration, while total remains run wall time; -- absent child receipt leaves child fields null; -- legacy final event behavior and existing metric-schema validation stay compatible. - -## Phase C — Immutable benchmark evidence and comparison - -### C1. Define a sealed-trial extractor - -Add `src/agent_workflow/eval/trials.py` (or a similarly focused module) that validates a completed run’s final receipt, provenance, execution metrics, and score verdict and produces one explicit trial record. Include run ID/path, schema/version, receipt digest, verdict, total wall duration, input/output/cached/total tokens, cost/currency, retry/error/steer summaries, and source file digests/paths. Derive `tokens` only if both input and output are known; derive cost/duration only when reported. No hidden filesystem scan or baseline choice. - -### C2. Add explicit evidence collection and compare CLI - -Add commands under `agent-workflow eval` to write a supplied set of run directories to a supplied JSON evidence file and compare two supplied evidence files using the existing comparator. Suggested shape: - -```text -agent-workflow eval collect --output baseline.json RUN_DIR [...] -agent-workflow eval compare baseline.json candidate.json --output comparison.json -``` - -The command must reject unsealed/incomplete inputs, duplicate trial IDs, incompatible currency aggregation, and malformed evidence. It must write atomically, include its own schema/version and collection timestamp, and print an inspectable result path. It must not change any run directory. - -### C3. Acceptance tests - -- extractor rejects placeholder/missing final receipts and accepts known-good sealed fixtures; -- output evidence is deterministic except for documented collection timestamp; -- comparator produces pass/fail/regression deltas for duration, token, and cost from explicit files; -- unknown numeric values remain unknown, not coerced to zero; -- invalid/cross-currency comparisons fail with a useful message; -- existing eval commands and schemas remain valid. - -## Phase D — Controlled real-executor cohort (operator gate) - -This phase is not a unit-test substitute. After Phases A-C merge, run 3–5 approved real sessions for the deterministic fixture under one pinned executor/version/configuration. Retain each sealed trial evidence record, inspect failures/resteers, and consciously designate exactly one baseline file. Run `eval compare` against a second cohort. Do not make release assertions from a cohort with changed prompt, model revision, tool policy, browser image, or pricing/currency semantics. - -For the visual fixture, remain blocked until a pinned browser image digest, installed-font manifest, browser-artifact bridge, and explicit child lifecycle telemetry contract exist. Then run DOM/keyboard/ARIA assertions plus screenshot comparison with declared tolerance. - -## Integration sequence - -1. Merge/review Phase A independently; run focused message/tmux/session tests. -2. Merge/review Phase B; run focused executor/metrics/runner tests. -3. Merge/review Phase C; run eval tests and CLI help/smoke commands. -4. Run `PYTHONPATH=src pytest -q`, `python -m build --wheel`, schema/doctor checks, and `scripts/release-check.sh`. -5. Bump version only after all code and docs land as one releaseable change; build/install the wheel; run a smoke session and verify `final-receipt.json` plus evidence collection. -6. Commit and push only after the phase gate accepts the combined diff. - -## Explicit out of scope - -- A daemon, Redis/NATS, or generic message bus. The durable JSONL control log remains the bus for this scope. -- A fake synchronous “steer” capability for one-shot executor stdin. -- Provider pricing inference or estimated costs. -- A screenshot visual eval without the pinned environment prerequisites. - -## ChatGPT research handoff prompt - -Use this only for the remaining external/prior-art deep dive; do not ask it to edit the repository blindly: - -```text -You are researching design options for the Python project agent-workflow. Do not write code. Produce a cited technical decision memo focused on two topics: - -1) Durable orchestration control delivery: append-only fsynced JSONL records are authoritative; tmux wait-for may be used only as a best-effort local wakeup accelerator. Compare tmux wait-for, filesystem notifications (inotify/watchfiles), SQLite notification patterns, Redis Streams, NATS JetStream, and a local Unix socket. Analyze delivery guarantees, restart behavior, lost/coalesced wakeups, multi-host implications, security boundaries, operational burden, and a migration path that keeps replayable durable records as source of truth. Recommend a staged architecture for single-host tmux today and optional multi-host tomorrow. - -2) Benchmark evidence: evaluate provider-neutral token/cost/time evidence for OpenAI/Codex-like streaming events and other common agent executors. Specify an explicit delta/cumulative/terminal usage event envelope; map known cached-token field variants; identify pitfalls that cause double-counting; and propose an immutable JSON trial-evidence schema plus baseline/candidate cohort comparison semantics. Include what must stay null, currency rules, retry/re-steer/error accounting, provenance digests, and a real-executor cohort protocol. - -Requirements: cite primary sources only; distinguish facts from recommendations; include concrete JSON examples and a compact decision matrix; do not recommend terminal keystroke injection as a steering API; no implementation patches. Finish with prioritized actionable recommendations, open questions, and tests/evals that would falsify each recommendation. -``` diff --git a/docs/Durable_Orchestration_Delivery_Benchmarks.md b/docs/Durable_Orchestration_Delivery_Benchmarks.md deleted file mode 100644 index bc9a619..0000000 --- a/docs/Durable_Orchestration_Delivery_Benchmarks.md +++ /dev/null @@ -1,811 +0,0 @@ -# Durable Orchestration Delivery and Benchmark Evidence for `agent-workflow` - -**Status:** Proposed architecture decision -**Scope:** Design research only; no implementation changes -**Date:** July 23, 2026 - -**Task tracking:** [BACKLOG.md](../BACKLOG.md) is the canonical register for -work derived from this research. Keep this document as the detailed evidence -and design reference; do not maintain a second task list here. - -## Executive decision - -`agent-workflow` should preserve **append-only, fsynced, replayable JSONL control records as the authoritative orchestration state**. Notification mechanisms should carry only an advisory statement equivalent to “new durable records may be available.” They must never carry the sole copy of a steer, acknowledgement, cancellation, or other control instruction. - -For the current single-host tmux deployment, use: - -1. durable JSONL append and `fsync`; -2. a monotonically advancing durable cursor per consumer; -3. `tmux wait-for` as an optional, best-effort wakeup accelerator; -4. periodic reconciliation polling as the correctness backstop. - -Filesystem notifications may later replace or complement tmux wakeups, but should remain advisory because kernel notification queues can overflow and events may be coalesced. For multi-host operation, introduce a broker adapter—preferably NATS JetStream or Redis Streams—without changing the durable control-record contract. The broker should distribute record references or replicated envelopes, while replayable records and idempotent consumer cursors remain the semantic source of truth. - -For benchmark evidence, normalize every executor’s telemetry into an immutable event stream that explicitly identifies whether usage is a **delta**, **cumulative snapshot**, or **terminal total**. Never sum a mixture of those modes. Preserve raw provider events and derive normalized trial evidence from them. Token and cost fields that cannot be proven from executor output or a pinned price catalog must remain `null`, not zero. - ---- - -# 1. Durable orchestration control delivery - -## 1.1 Required invariant - -A control operation is accepted only after its complete JSONL record has been appended and durably synchronized. A wakeup signal may be sent only after that durability point. - -Conceptually: - -```text -validate record -→ append complete JSON line -→ flush userspace buffers -→ fsync durable file -→ publish advisory wakeup -``` - -The consumer reacts to any wakeup by replaying records after its durable cursor. If no wakeup arrives, periodic reconciliation eventually discovers the record. - -This architecture separates two concerns: - -* **Durable truth:** what control records exist and in what order. -* **Scheduling hint:** how quickly an idle process notices that truth changed. - -That separation is necessary because none of the local wakeup mechanisms evaluated below independently provides the desired combination of durable replay, crash recovery, ordering, and migration portability. - -## 1.2 Facts about candidate mechanisms - -### tmux `wait-for` - -The tmux manual defines `wait-for name` as blocking until another client executes `wait-for -S name`. It also provides channel locking through `-L` and `-U`. The interface is managed by the running tmux server rather than by a persistent message store. - -**Implications:** - -* It is suitable for waking processes attached to the same tmux server. -* It provides no independently replayable record. -* A tmux-server restart destroys the live waiting relationship. -* A signal issued before a process begins waiting must not be assumed to become a durable queued notification. -* It is inherently single-host and tied to a specific tmux server socket and security context. - -### Filesystem notifications: inotify and watchfiles - -Linux inotify reports filesystem changes through a kernel event queue. The Linux manual explicitly states that the queue can overflow and events are then lost; robust applications must recover by rebuilding state. It also documents event coalescing when successive unread events are identical. - -`watchfiles` exposes sets of filesystem changes and recursively watches directories, but it remains an abstraction over operating-system notification facilities rather than a durable event log. - -**Implications:** - -* Notifications can reduce latency without a tmux dependency. -* Multiple appends may produce one observed change notification. -* Queue overflow or watcher restart can lose wakeups. -* Watching a directory is generally safer than assuming an inode remains stable across atomic replacement or log rotation. -* Correctness still requires checking durable file state and cursor position. - -### SQLite notification patterns - -SQLite’s `sqlite3_update_hook()` is registered on a specific database connection. It has documented exclusions, including changes to internal tables, `WITHOUT ROWID` tables, certain `REPLACE` deletions, and truncate optimization; callback code also cannot modify the invoking connection. - -SQLite WAL improves reader/writer concurrency but does not turn SQLite into a cross-process push-notification service. Durable cross-process change discovery generally requires polling a durable table, polling `PRAGMA data_version`, or adding a separate wakeup channel. - -**Implications:** - -* A transactional inbox table can provide strong durable ordering, uniqueness, acknowledgement, and cursor semantics. -* Connection-local hooks are not a general multi-process notification bus. -* SQLite introduces schema migration, locking, backup, corruption-recovery, and database-lifecycle concerns that are unnecessary if JSONL already satisfies the authoritative-record requirement. -* SQLite is more compelling as a future materialized index than as the immediate wakeup mechanism. - -### Redis Streams - -Redis Streams retain ordered entries, support replay, consumer groups, pending-entry tracking, acknowledgement, and recovery of entries left pending by failed consumers. Redis documents consumer-group processing as at-least-once and distinguishes Streams from Pub/Sub, whose delivery is at-most-once and loses messages for disconnected subscribers. - -**Implications:** - -* Redis Streams can distribute work across hosts and recover unacknowledged deliveries. -* Consumers must remain idempotent because redelivery is expected. -* Persistence depends on Redis durability configuration, retention policy, failover design, and operational discipline. -* Trimming a stream before all required durable consumers have advanced can remove replay evidence. -* Redis becomes another stateful service that must be secured, monitored, backed up, and upgraded. - -### NATS JetStream - -Core NATS provides best-effort, at-most-once delivery. JetStream adds stored streams and consumers that can provide at-least-once delivery. NATS documents stronger “exactly once” behavior as a composition of publication deduplication and double acknowledgements, not as freedom from application-level identity and idempotency design. - -JetStream streams define storage and retention limits, while durable consumers retain delivery state. - -**Implications:** - -* JetStream is well suited to multi-host command distribution and request/reply messaging. -* Durable names, acknowledgement policy, redelivery timing, retention, replicas, and deduplication windows become part of correctness. -* Application-level record IDs remain necessary beyond any bounded broker deduplication window. -* It introduces a networked security boundary and a stateful clustered service. - -### Local Unix-domain socket - -Unix-domain sockets support local interprocess communication. On Linux, pathname socket access can be constrained by directory and socket permissions, but POSIX does not require all systems to honor pathname-socket permissions consistently; Linux abstract sockets do not use filesystem permissions. - -**Implications:** - -* A pathname socket can provide low-latency local wakeups and bidirectional status exchange. -* A disconnected process misses any notification that was not separately persisted. -* Stream sockets require connection management, framing, backpressure, and stale-socket cleanup. -* The socket server becomes a local availability dependency. -* It does not naturally extend across hosts without replacing the transport. -* Pathname sockets should live in a private runtime directory with restrictive ownership and mode; abstract sockets should not be treated as permission-protected endpoints. - -## 1.3 Compact decision matrix - -Ratings describe fitness for the stated `agent-workflow` role, not absolute product quality. - -| Mechanism | Durable replay | Restart behavior | Wakeup loss/coalescing | Multi-host | Security boundary | Operational burden | Recommended role | -| -------------------- | ------------------------------------: | ---------------------------------------- | -------------------------------------------------- | ------------------------------------: | ------------------------------------------------------- | -----------------: | ----------------------------------- | -| tmux `wait-for` | No | Lost with tmux server state | Treat as lossy edge signal | No | tmux server/socket and local account | Very low | Immediate local accelerator | -| inotify/watchfiles | No | Watch must be recreated | Queue overflow and coalescing possible | No | Filesystem access plus process identity | Low | Generic local accelerator | -| SQLite inbox/polling | Yes | Durable DB recovery | Polling avoids wakeup dependence | Shared filesystem only; not ideal WAN | DB-file permissions | Medium | Optional indexed local store | -| Redis Streams | Yes, subject to persistence/retention | Consumer pending state supports recovery | At-least-once; redelivery expected | Yes | Network ACL/TLS/account boundary | Medium–high | Optional distributed adapter | -| NATS JetStream | Yes, subject to stream configuration | Durable consumers and stream replay | At-least-once by default; dedupe/acks configurable | Yes | NATS accounts, credentials, TLS, subject permissions | Medium–high | Preferred distributed control plane | -| Unix socket | No | Listener and clients reconnect | Disconnected clients miss signals | No | Local directory/socket permissions and peer credentials | Medium | Optional local RPC/wakeup channel | - -## 1.4 Recommended staged architecture - -### Stage A — single-host tmux - -**Recommendation** - -Keep one append-only control journal per orchestration scope, or one global journal with an explicit orchestration ID. Each record has a globally unique record ID and a monotonically ordered sequence assigned under an exclusive append lock. - -After `fsync`, the producer may execute a tmux signal such as: - -```text -tmux wait-for -S agent-workflow::changed -``` - -The consumer: - -1. reads from its last durably committed sequence; -2. validates complete lines and record identities; -3. handles every applicable record idempotently; -4. durably advances its cursor; -5. begins waiting again; -6. also wakes on a bounded reconciliation interval. - -The tmux channel name must not be treated as a message queue or contain the command payload. - -**Why** - -This adds minimal operational complexity while preserving correctness if: - -* tmux is unavailable; -* the signal arrives before the waiter; -* the tmux server restarts; -* several appends collapse into one observed signal; -* the producer crashes after `fsync` but before signaling. - -### Stage B — transport-neutral notifier interface - -Define an internal conceptual interface with only advisory semantics: - -```text -notify(scope_id, highest_durable_sequence) -wait(scope_id, timeout) → advisory_high_watermark | timeout -``` - -The returned high-water mark is a hint. The consumer still reads the authoritative log and never assumes all lower records were processed merely because it observed a larger wakeup value. - -Provide tmux and filesystem-watcher adapters. Keep periodic reconciliation enabled for both. - -### Stage C — durable record indexing - -Add a materialized index only when JSONL scan cost becomes measurable. SQLite is a reasonable single-host index for: - -* record ID uniqueness; -* per-consumer cursors; -* acknowledgement relationships; -* searchable status projections. - -The JSONL journal should remain independently replayable. The SQLite database should be reconstructable from the journal, or journal and database should be updated through a carefully specified transaction/outbox protocol. Avoid declaring two independently writable stores authoritative. - -### Stage D — optional multi-host broker - -Introduce a broker adapter without changing the durable record envelope. - -Two defensible patterns are: - -**Reference distribution** - -```json -{ - "kind": "control-record-available", - "scope_id": "run-2026-07-23-001", - "record_id": "01JZ...", - "sequence": 184, - "journal_uri": "artifact://runs/run-2026-07-23-001/control.jsonl", - "record_sha256": "..." -} -``` - -The consumer retrieves and validates the durable record from shared artifact storage. - -**Envelope replication** - -Publish the complete canonical record to JetStream or Redis Streams, while archiving the same canonical bytes in immutable trial/run evidence. Broker sequence numbers are transport metadata, not business record identity. - -Prefer NATS JetStream when the project needs subject-based routing, service communication, and multi-tenant authorization. Prefer Redis Streams when Redis is already an accepted operational dependency and stream/consumer-group semantics are sufficient. Do not introduce either solely to improve same-host wakeup latency. - -## 1.5 Control-record envelope - -Recommended durable record: - -```json -{ - "schema": "agent-workflow.control-record.v1", - "record_id": "01JZ7FJCNMDDTHTM55ABAPQ4KB", - "scope_id": "run-2026-07-23-001", - "sequence": 184, - "occurred_at": "2026-07-23T21:18:42.413927Z", - "kind": "steer", - "direction": "orchestrator-to-agent", - "producer": { - "actor_id": "orchestrator", - "host_id": "sha256:...", - "process_instance_id": "01JZ..." - }, - "target": { - "actor_id": "agent-review-02" - }, - "correlation_id": null, - "payload": { - "instruction": "Re-run the failing fixture with the pinned environment.", - "reason": "candidate result lacks required evidence" - }, - "payload_sha256": "sha256:...", - "previous_record_sha256": "sha256:...", - "record_sha256": "sha256:..." -} -``` - -Hash chaining is recommended for tamper evidence, not as a substitute for filesystem permissions, signatures, or immutable storage. - -## 1.6 Explicit non-decision: terminal keystrokes - -Terminal keystroke injection should not be an orchestration steering API. - -It lacks a stable machine contract, can be interpreted differently depending on terminal state and foreground application, does not inherently prove durable acceptance, is difficult to authenticate at the command level, and is not portable to nonterminal executors. - -A terminal may remain a human observation and emergency-intervention surface. Machine steering should use durable control records consumed by an executor integration. - ---- - -# 2. Provider-neutral benchmark evidence - -## 2.1 Evidence principles - -A benchmark trial should preserve three layers: - -1. **Raw evidence:** exact executor events or bounded canonical captures. -2. **Normalized events:** provider-neutral usage and lifecycle envelopes. -3. **Derived summary:** trial totals and cohort statistics. - -The normalized and derived layers must identify the raw evidence digests from which they were produced. Raw evidence must not be overwritten after trial sealing. - -OpenAI’s Agents SDK tracks aggregate request count, input tokens, output tokens, total tokens, cached-input details, reasoning-output details, and per-request usage entries; it also aggregates across model calls, tool calls, and handoffs. - -Anthropic exposes input, output, cache-creation, and cache-read token fields. Anthropic explicitly states that total request input is the sum of ordinary input, cache-creation input, and cache-read input. - -Gemini billing can depend on input, output, cached-token count, and cache-storage duration, so a generic “input plus output” model is insufficient for all executors. - -## 2.2 Usage event envelope - -Every normalized usage event should state its accounting mode. - -```json -{ - "schema": "agent-workflow.usage-event.v1", - "event_id": "01JZ7G6AJXVPWQG2K9T7C6Q9AC", - "trial_id": "trial-0042", - "attempt_id": "attempt-0003", - "request_id": "req-provider-or-local-id", - "executor": { - "family": "codex-cli", - "version": "0.137.0-alpha.4", - "provider": "openai", - "model": "gpt-5.6-sol" - }, - "observed_at": "2026-07-23T21:24:07.902183Z", - "event_role": "usage", - "usage_mode": "delta", - "terminal": false, - "usage": { - "input_tokens": 320, - "cached_input_tokens": 2048, - "cache_write_input_tokens": null, - "output_tokens": 77, - "reasoning_output_tokens": 41, - "total_tokens": null - }, - "cost": { - "amount": null, - "currency": null, - "basis": null, - "pricing_catalog_sha256": null - }, - "source": { - "source_type": "executor-jsonl", - "source_event_type": "token_count", - "source_sequence": 981, - "raw_event_sha256": "sha256:..." - } -} -``` - -Allowed `usage_mode` values: - -* `delta`: usage newly incurred since the previous usage event for the same request or attempt; -* `cumulative`: running total through this event; -* `terminal`: authoritative final total for the identified request or attempt. - -`terminal` is a lifecycle property. A terminal event may itself use `delta`, `cumulative`, or `terminal` accounting semantics. To reduce ambiguity, providers that emit a final total should use `usage_mode: "terminal"` and `terminal: true`. - -### Cumulative example - -```json -{ - "schema": "agent-workflow.usage-event.v1", - "event_id": "01JZ7G7KXKGP0RG1P8PV5Z6J6A", - "trial_id": "trial-0042", - "attempt_id": "attempt-0003", - "request_id": "req-17", - "observed_at": "2026-07-23T21:24:09.114082Z", - "event_role": "usage", - "usage_mode": "cumulative", - "terminal": false, - "usage": { - "input_tokens": 16000, - "cached_input_tokens": 12000, - "cache_write_input_tokens": null, - "output_tokens": 640, - "reasoning_output_tokens": 201, - "total_tokens": 16640 - }, - "source": { - "source_type": "executor-jsonl", - "source_event_type": "token_count", - "source_sequence": 982, - "raw_event_sha256": "sha256:..." - } -} -``` - -### Terminal example - -```json -{ - "schema": "agent-workflow.usage-event.v1", - "event_id": "01JZ7G9BDTET1RZBQH5Q4WKGBW", - "trial_id": "trial-0042", - "attempt_id": "attempt-0003", - "request_id": "req-17", - "observed_at": "2026-07-23T21:24:17.887310Z", - "event_role": "usage", - "usage_mode": "terminal", - "terminal": true, - "usage": { - "input_tokens": 18944, - "cached_input_tokens": 14336, - "cache_write_input_tokens": null, - "output_tokens": 982, - "reasoning_output_tokens": 326, - "total_tokens": 19926 - }, - "finish": { - "status": "completed", - "reason": "end_turn" - }, - "source": { - "source_type": "executor-jsonl", - "source_event_type": "turn.completed", - "source_sequence": 990, - "raw_event_sha256": "sha256:..." - } -} -``` - -## 2.3 Cached-token field mapping - -| Provider/executor field | Normalized field | Treatment | -| ----------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------- | -| OpenAI `input_tokens_details.cached_tokens` | `cached_input_tokens` | Cache-read subset of input | -| Codex `cached_input_tokens` | `cached_input_tokens` | Preserve as reported | -| Codex rollout `total_token_usage.cached_input_tokens` | `cached_input_tokens` | Usually cumulative when under `total_token_usage` | -| Codex rollout `last_token_usage.cached_input_tokens` | `cached_input_tokens` | Potential delta, but verify total advancement before accepting | -| OpenAI `cache_write_tokens`, when exposed | `cache_write_input_tokens` | Preserve separately; do not merge with cache reads | -| Anthropic `cache_read_input_tokens` | `cached_input_tokens` | Cache-read input | -| Anthropic `cache_creation_input_tokens` | `cache_write_input_tokens` | Cache-created/written input | -| Anthropic `input_tokens` | `uncached_input_tokens`, if schema adds it | Do not assume it already includes cached fields | -| Gemini `cachedContentTokenCount` | `cached_input_tokens` | Cache-related input usage | -| Gemini `promptTokenCount` | provider-total input | Determine from API semantics whether cached count is a subset before deriving uncached input | -| Gemini cache storage duration | separate cost dimension | Never encode as tokens | - -OpenAI’s Agents SDK uses `input_tokens_details.cached_tokens` and `output_tokens_details.reasoning_tokens`. - -Codex public issue records show that `turn.completed.usage` has used `cached_input_tokens`, while rollout records have exposed both `total_token_usage` and `last_token_usage`. They also document a concrete double-counting hazard: rate-limit-only updates may repeat unchanged `last_token_usage` even when cumulative totals do not advance. - -Anthropic’s `input_tokens` is not equivalent to total context input when caching is active; total input is `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`. - -## 2.4 Double-counting pitfalls - -### Mixing cumulative and delta events - -Never add every observed token event. For each request or attempt, use one of: - -* sum verified deltas; -* take the latest valid cumulative snapshot; -* take the authoritative terminal total. - -If a terminal total exists, use it as the trial accounting authority and use earlier deltas only for temporal analysis. - -### Repeated cumulative snapshots - -A stream may emit the same cumulative value multiple times. Deduplicate by request identity, usage vector, source sequence, and raw digest. Repeated snapshots contribute zero incremental usage. - -### Repeated “last usage” records - -A field named `last_token_usage` is not sufficient proof of a new delta. Accept it only if: - -* the corresponding cumulative total advanced by exactly that amount; or -* the executor specification explicitly guarantees one-time delta emission and the event identity is new. - -Codex has had a reported behavior where rate-limit updates re-emitted prior `last_token_usage`, causing consumers that summed those fields to overcount. - -### Nested aggregates - -Do not add parent-run totals to child-request totals. OpenAI’s Agents SDK aggregate already includes calls made through tools and handoffs. - -Store hierarchy: - -```text -trial - attempt - executor process - model request -``` - -Aggregate only from the lowest complete nonoverlapping level, or use one authoritative parent total. - -### Cached tokens as both subset and additive category - -Provider semantics differ: - -* OpenAI-style cached input is generally represented as detail within input tokens. -* Anthropic’s ordinary input, cache creation, and cache read are separate additive fields. -* Gemini fields require API-version-specific interpretation. - -Therefore, never calculate: - -```text -total input = input_tokens + cached_input_tokens -``` - -without a provider mapping that declares whether cached tokens are already included. - -### Reasoning tokens - -Reasoning tokens may be a subset of output tokens rather than additional tokens. Store both but do not add reasoning tokens to output unless the provider explicitly defines them as disjoint. Codex JSON output has historically omitted reasoning detail even when it existed in rollout records, demonstrating why absent detail must remain `null`. - -### Retries and reconnects - -A transport reconnect may replay the terminal event for the same request. Deduplicate by provider request ID and event identity. - -A true provider retry is a new request and its usage counts, even when it was automatically initiated and returned no useful answer. - -## 2.5 Immutable trial-evidence schema - -```json -{ - "schema": "agent-workflow.trial-evidence.v1", - "trial_id": "trial-0042", - "cohort_id": "candidate", - "benchmark_case_id": "case-repo-review-017", - "replicate": 3, - "sealed_at": "2026-07-23T21:25:02.117912Z", - "subject": { - "implementation_digest": "sha256:...", - "prompt_pack_digest": "sha256:...", - "configuration_digest": "sha256:..." - }, - "environment": { - "os": "linux", - "architecture": "x86_64", - "host_class": "benchmark-standard-8", - "container_image_digest": "sha256:...", - "executor_family": "codex-cli", - "executor_version": "0.137.0-alpha.4", - "provider": "openai", - "model": "gpt-5.6-sol", - "model_revision": null - }, - "timing": { - "scheduled_at": "2026-07-23T21:18:00Z", - "process_started_at": "2026-07-23T21:18:01.104Z", - "first_output_at": "2026-07-23T21:18:03.890Z", - "terminal_at": "2026-07-23T21:24:17.887Z", - "wall_duration_ms": 376783, - "first_output_latency_ms": 2786, - "active_executor_ms": null - }, - "usage": { - "requests": 7, - "input_tokens": 82144, - "cached_input_tokens": 59136, - "cache_write_input_tokens": null, - "output_tokens": 6132, - "reasoning_output_tokens": 1820, - "total_tokens": 88276, - "normalization_status": "complete-terminal", - "accounting_source": "request-terminal-events" - }, - "cost": { - "amount": null, - "currency": null, - "pricing_catalog_sha256": null, - "calculation_status": "not-computable" - }, - "control": { - "steer_count": 2, - "acknowledged_steer_count": 2, - "rejected_steer_count": 0, - "retry_count": 1, - "executor_restart_count": 0 - }, - "outcome": { - "status": "completed", - "score": 0.86, - "score_schema": "agent-workflow.eval-score.v2", - "error_class": null - }, - "provenance": { - "raw_event_manifest_sha256": "sha256:...", - "normalized_usage_jsonl_sha256": "sha256:...", - "control_jsonl_sha256": "sha256:...", - "stdout_sha256": "sha256:...", - "stderr_sha256": "sha256:...", - "result_artifact_manifest_sha256": "sha256:...", - "pricing_snapshot_sha256": null, - "normalizer_version": "usage-normalizer-v1", - "normalizer_digest": "sha256:..." - } -} -``` - -After sealing: - -* fields are not edited in place; -* corrections create a superseding evidence object; -* the superseding object identifies the prior digest and correction reason; -* cohort analyses identify the exact evidence-object digests included. - -## 2.6 Fields that must remain `null` - -Use `null`, not zero, when: - -* the executor reports no token information; -* a streaming run terminates before authoritative usage is emitted; -* cached-token semantics cannot be mapped confidently; -* reasoning tokens are not exposed; -* cache-write usage is unavailable; -* model revision is not provided; -* active compute time cannot be distinguished from wall time; -* provider billing is subscription-, quota-, or credit-based and no per-trial cost is attributable; -* the pricing catalog is not pinned; -* a price tier depends on undisclosed provider-side context; -* taxes, discounts, committed-use pricing, batch discounts, regional premiums, or negotiated rates are unknown; -* the terminal event was missing and deltas are incomplete. - -Zero means “observed and equal to zero.” Null means “unknown or not applicable.” - -## 2.7 Currency and cost rules - -Provider pricing is time-variable and may distinguish ordinary input, cached input, output, cache storage, service tier, batch mode, context length, or modality. Official OpenAI, Anthropic, and Gemini pricing pages expose differing categories and rates. - -Recommendations: - -1. Store provider-reported billed cost when available, separately from locally estimated cost. -2. Store currency as ISO 4217 uppercase, normally `USD`. -3. Do not convert currencies inside immutable trial evidence. -4. Store the original amount and currency; perform FX conversion only in a derived report with a separately pinned FX source and date. -5. Calculate cost only from a captured pricing catalog containing: - - * provider; - * exact model identifier; - * effective timestamp; - * service tier; - * token category; - * unit size; - * currency; - * context-length thresholds; - * batch or priority modifiers; - * source digest. -6. Round only for display. Preserve calculation precision in decimal form. -7. If any priced category is unknown, total estimated cost remains `null`; a partial-cost field may be reported explicitly as partial. - -## 2.8 Retry, re-steer, and error accounting - -### Retry - -Every provider request attempt receives a distinct `request_id` or local surrogate. All billable usage from failed and successful attempts counts toward resource evidence. - -Trial fields should distinguish: - -```json -{ - "attempt_count": 3, - "successful_attempt_count": 1, - "failed_attempt_count": 2, - "retry_count": 2 -} -``` - -Do not report only the successful attempt’s cost. - -### Re-steer - -A re-steer is a control event, not automatically a new benchmark trial. It remains within the same trial when it is part of the predetermined protocol. - -Record: - -* durable steer record ID; -* target; -* reason category; -* timestamp; -* acknowledgement record ID; -* first model request causally following the steer; -* whether the protocol allowed that steer. - -Unplanned human steering invalidates a fully autonomous cohort comparison unless both cohorts receive an equivalent, preregistered intervention protocol. - -### Error - -An errored request may still have billable tokens. Preserve usage independently from outcome. - -Recommended status vocabulary: - -```text -completed -failed-executor -failed-provider -timed-out -cancelled -killed -evidence-incomplete -invalid-protocol -``` - -A timeout should record both the benchmark deadline and the eventual process termination time. - -## 2.9 Baseline/candidate cohort semantics - -A cohort comparison must use paired benchmark cases wherever possible. - -For each case: - -* run baseline and candidate against the same immutable input; -* use the same executor family/version, model, tool policy, resource limits, environment image, and evaluator; -* randomize or alternate run order; -* use multiple independent replicates; -* record warm/cold cache policy; -* avoid sharing conversational state across trials; -* define timeout and retry policy before execution; -* seal evidence before scoring aggregation. - -Primary comparisons: - -```text -paired score difference -paired wall-time ratio -paired token ratio -paired attributable-cost ratio -success-rate difference -steer/retry/error-rate difference -``` - -Report distributions and confidence intervals, not just grand totals. Token and cost comparisons should exclude neither failures nor retries; instead present: - -* unconditional cost per scheduled trial; -* cost per successful trial; -* success-adjusted utility; -* failure-specific resource consumption. - -Do not merge trials with materially different models or pricing epochs into one homogeneous cost cohort. Stratify them or normalize only in a clearly labeled derived analysis. - -## 2.10 Real-executor cohort protocol - -1. Freeze benchmark case inputs and expected scoring rules. -2. Freeze baseline and candidate artifact digests. -3. Pin executor version, model ID, environment image, tool permissions, and timeout. -4. Capture executor capability metadata before trials. -5. Run a telemetry calibration case to determine: - - * whether usage events are delta, cumulative, or terminal; - * cached-token semantics; - * whether reasoning tokens are included in output; - * behavior on reconnect, retry, cancellation, and errors. -6. Execute at least one cold-cache and one warm-cache block where caching matters. -7. Use randomized paired order across cases. -8. Preserve raw streaming events, stdout, stderr, control events, and produced artifacts. -9. Normalize only after raw evidence is sealed. -10. Reject or mark incomplete any trial whose usage accounting cannot be reconciled. -11. Run the evaluator from sealed artifacts, not from mutable working directories. -12. Publish the exact cohort manifest containing every included and excluded trial digest and exclusion reason. - ---- - -# 3. Prioritized actionable recommendations - -## Priority 0 — protect the source-of-truth boundary - -**Recommendation:** Declare fsynced append-only control records authoritative and every notifier advisory. - -**Falsifying evaluation:** Kill producers and consumers at every boundary around append, flush, `fsync`, signal, read, execute, and cursor commit. The recommendation fails if a durably accepted record can become permanently undiscoverable or if a non-durable record is executed as accepted. - -## Priority 1 — use tmux only for present-day wakeups - -**Recommendation:** Add tmux `wait-for` only as a post-`fsync` accelerator, with reconciliation polling. - -**Falsifying evaluation:** Repeatedly signal before wait, during tmux-server restart, during consumer restart, and during bursts of thousands of appends. The design fails if final execution differs from pure replay of the durable journal. - -## Priority 2 — require idempotent consumers and durable cursors - -**Recommendation:** Identify every control record globally and commit each consumer cursor only after successful handling or durable terminal disposition. - -**Falsifying evaluation:** Replay every record multiple times, crash during handling, and reorder advisory wakeups. The design fails if externally visible effects occur more than once where the control contract requires once-only behavior. - -## Priority 3 — standardize usage-mode semantics before adding providers - -**Recommendation:** Require every usage event to declare `delta`, `cumulative`, or `terminal`; reject ambiguous aggregation. - -**Falsifying evaluation:** Feed the normalizer duplicated cumulative events, replayed terminal events, Codex-style repeated `last_token_usage`, and mixed nested aggregates. The recommendation fails if normalized totals differ from authoritative terminal totals. - -## Priority 4 — preserve raw events and immutable evidence - -**Recommendation:** Seal raw executor evidence before normalization and identify all derived artifacts by digest. - -**Falsifying evaluation:** Re-run normalization from sealed raw evidence on a clean machine. The design fails if normalized evidence is nondeterministic without an explicitly recorded environmental dependency. - -## Priority 5 — treat unknown evidence as null - -**Recommendation:** Prohibit conversion of missing token, reasoning, cache, time, or cost data to zero. - -**Falsifying evaluation:** Use executors that omit terminal usage, cache-write details, reasoning detail, or price metadata. The design fails if reports imply a measured zero or rank such trials as cheaper solely because evidence was absent. - -## Priority 6 — introduce distributed messaging only for a demonstrated need - -**Recommendation:** Delay Redis Streams or NATS JetStream until workers must cross a host boundary or local journal scan/wakeup behavior fails an explicit service objective. - -**Falsifying evaluation:** Measure end-to-end steering latency, CPU wakeups, missed-advisory recovery time, and operational incidents on the single-host design. This recommendation is falsified if local mechanisms cannot meet the defined latency or availability objective without broker semantics. - -## Priority 7 — prefer JetStream for a future general control plane - -**Recommendation:** If multi-host orchestration becomes necessary and no existing broker is mandated, evaluate NATS JetStream first; retain a Redis Streams adapter boundary. - -**Falsifying evaluation:** Prototype identical workloads under fault injection: broker restart, network partition, duplicate publication, consumer crash, slow consumer, credential revocation, retention pressure, and replay. Reject JetStream if it cannot meet recovery and operational objectives more simply than Redis within the project’s actual deployment environment. - ---- - -# 4. Open questions - -1. Is the authoritative JSONL journal stored on a local disk, network filesystem, or immutable object/artifact store? -2. Does `fsync` durability need to survive only process and OS crashes, or also host/power/storage-controller failures? -3. Is strict global order required, or only order per orchestration scope or target agent? -4. May multiple producers append to one journal, and what assigns the sequence? -5. Which control operations require exactly-once external effects rather than idempotent at-least-once processing? -6. What maximum steering-detection latency is acceptable without a wakeup? -7. Will multi-host workers share an artifact store from which durable records can be fetched? -8. Must records be cryptographically signed across trust domains, or is host-level access control sufficient? -9. Which real executors are required in the first benchmark cohort: Codex CLI, OpenAI Agents SDK, Claude Code/Agent SDK, Gemini-based agents, or others? -10. Are trials billed through direct APIs, subscriptions, enterprise quotas, or mixed arrangements? -11. Should benchmark cost represent list-price estimate, invoice-attributed spend, or both? -12. Are warm-cache trials a target production condition or merely a diagnostic cohort? -13. What minimum replicate count and practical effect threshold should gate candidate acceptance? -14. How should interrupted or human-assisted trials affect the primary score? - -# Final decision statement - -Adopt **durable-log-first orchestration**: fsynced JSONL records, durable consumer cursors, idempotent replay, tmux wakeups today, and transport-neutral broker adapters later. Notifications optimize latency; they never establish truth. - -Adopt **raw-first immutable benchmark evidence**: explicit delta/cumulative/terminal usage semantics, provider-specific cache mapping, null-preserving normalization, pinned pricing provenance, complete retry and error accounting, and paired real-executor cohorts built from sealed trial artifacts. diff --git a/docs/EVIDENCE_AND_EVALUATION.md b/docs/EVIDENCE_AND_EVALUATION.md new file mode 100644 index 0000000..89d00aa --- /dev/null +++ b/docs/EVIDENCE_AND_EVALUATION.md @@ -0,0 +1,49 @@ +# Evidence and evaluation + +`agent-workflow` separates observations, durable records, sealed artifacts, and evaluator conclusions so that mutable convenience files cannot become authority by accident. + +## Evidence hierarchy + +1. **Operator observations:** terminal output, tmux capture, and logs help diagnose a run but do not authorize transitions. +2. **Durable records:** append-only message, lifecycle, assignment, and workflow journals reconstruct actions and state. +3. **Sealed run evidence:** the final receipt commits to required artifacts and their sizes and SHA-256 digests. +4. **Lifecycle receipts:** review, acceptance, and rejection bind an actor, reason, revision, and validated sealed evidence. +5. **Workflow receipt:** a terminal aggregate commits to the workflow snapshot, journal, node bindings, child receipts, approvals, retry lineage, and disposition. +6. **Evaluation evidence:** scorer receipts, score sets, trial evidence, and cohort comparisons bind conclusions to sealed source evidence. + +Status JSON files are projections and may be regenerated. + +## Provider event envelope + +Structured provider evidence records a bounded raw JSONL stream and a normalized envelope. Usage-bearing records are explicitly classified as: + +- `delta`: add each unique event exactly once; +- `cumulative`: use the latest monotonic total for that stream; +- `terminal`: authoritative terminal totals that replace, rather than add to, nonterminal usage. + +A provider event ID is idempotent only when the repeated payload digest is identical. Conflicting reuse invalidates the usage evidence. Mixed nonterminal modes, non-finite values, decreasing cumulative totals, empty terminal usage, or incomplete cost metadata fail closed. + +Mapped usage fields include input, cached input, cache-write input, output, reasoning output, and provider total tokens. Unknown or unavailable values remain `null`; absence is never converted to zero. + +## Cost rules + +Provider-billed cost and local catalog estimates are distinct fields. A local estimate must record catalog identity, currency, and sufficient token evidence. Currency values are never converted or combined implicitly. Retries and re-steers remain separate evidence dimensions rather than hidden inside one aggregate price. + +## Trial evidence + +A trial binds: + +- task and repetition identity; +- base revision and fixture revision; +- prompt, oracle, acceptance-command, scope-policy, budget, and scorer-version digests; +- final receipt, raw provider stream, normalized provider evidence, and score receipts; +- duration, usage, cost, retry, steering, error, and verdict fields; +- explicit exclusions or missing evidence. + +Score-set verdicts are accepted only after their content-addressed scorer receipts and the sealed final receipt are validated. + +## Cohort comparison + +Baseline and candidate cohorts must contain matching task/repetition identities and compatible evidence semantics. Comparisons report descriptive rates and paired outcomes. They do not declare a winner when sample size or configured evidence is insufficient. + +Real paid-provider comparisons remain an operator-run gate. Before publishing a claim, pin executor version, model, environment, tool policy, prompt/fixture revision, repetitions, exclusions, billing meaning, and cache policy. Track that work under `BKL-004` in [BACKLOG.md](../BACKLOG.md). diff --git a/docs/GLOBAL_AGENT_ROUTING.md b/docs/GLOBAL_AGENT_ROUTING.md deleted file mode 100644 index 92f5778..0000000 --- a/docs/GLOBAL_AGENT_ROUTING.md +++ /dev/null @@ -1,36 +0,0 @@ -# Global agent routing and enforcement - -## Decision - -Use global agent instructions and the installed `agent-workflow-orchestrator` -skill to route work by intent. Add host hooks only as narrow, opt-in enforcement -for recognizable direct delegation commands. Do not claim that a hook can infer -whether every prompt should become a durable workflow run. - -The durable boundary remains `agent-workflow launch`. A raw host-native subagent, -`tmux split-window`, `claude`, or `codex` process is not an agent-workflow run and -does not gain its worktree, receipts, message log, class/model policy, or review -gates. - -## Host behavior - -| Host | Semantic routing | Enforceable boundary | -|---|---|---| -| Codex | Global `AGENTS.md` plus the installed orchestration skill | Codex has no repository-supported semantic hook API. Rules may constrain exact command prefixes, but must not replace task classification. | -| Claude Code | Global instructions plus the installed orchestration skill | An opt-in `PreToolUse` hook may reject known raw delegation shell patterns and direct the caller to `agent-workflow launch`. Existing hooks must be preserved. | - -## Required hook properties - -- installer-managed and opt-in; uninstall removes only files and settings it owns; -- idempotent merge with existing host configuration; -- fail closed only for exact direct-delegation patterns, never broad shell use; -- emit a concise replacement command and preserve a local audit record; -- allow an explicit, recorded break-glass bypass; -- test Codex and Claude configuration independently using their actual nouns and - flags; -- never rewrite native host subagent APIs transparently, because they do not - provide an agent-workflow worktree or durable evidence contract. - -Until that adapter is implemented and tested, the supported enforcement is the -orchestration skill plus application-level executor, model, permission, agent -name, and agent-class validation. diff --git a/docs/MCP_IMPLEMENTATION_CHECKLIST.md b/docs/MCP_IMPLEMENTATION_CHECKLIST.md deleted file mode 100644 index 35fba4a..0000000 --- a/docs/MCP_IMPLEMENTATION_CHECKLIST.md +++ /dev/null @@ -1,36 +0,0 @@ -# MCP implementation checklist - -## MCP-003 safe mutation gate - -- [ ] Define versioned request/result/event schemas. -- [ ] Add append-only fsynced request journal and replay projection. -- [ ] Reserve idempotency keys before external effects. -- [ ] Reconcile accepted-but-nonterminal operations after restart. -- [ ] Extract only the minimum CLI logic needed into shared services. -- [ ] Add `worktree_create` without arbitrary paths. -- [ ] Add `run_launch` through canonical session launch and policy selection. -- [ ] Add workflow validate/start/status/resume/seal/verify through `WorkflowService`. -- [ ] Add progress/ack/steer through durable message APIs. -- [ ] Preserve `pending` steering semantics. -- [ ] Add request resource with bounded status/result evidence. -- [ ] Add CLI/MCP equivalence tests. -- [ ] Add traversal, symlink, redaction, bounds, conflict, crash, and replay tests. -- [ ] Run MCP Inspector and publish capability matrix. -- [ ] Update README, command reference, man page, skills, backlog, changelog, and release manifest. - -## MCP-004 destructive/review gate - -- [ ] Obtain explicit authorization after MCP-003 evidence review. -- [ ] Add per-action feature flags. -- [ ] Add interrupt/terminate only; keep force kill excluded unless separately approved. -- [ ] Add review/accept/reject through lifecycle service. -- [ ] Require actor, reason, exact revision, score/final-receipt validity, and reviewer independence. -- [ ] Add duplicate-transition and stale/forged evidence tests. -- [ ] Repeat Inspector, host, security, full-suite, and release gates. - -## MCP-005 HTTP decision gate - -- [ ] Record actual adoption/multi-process need. -- [ ] Write authorization/deployment ADR. -- [ ] Decide tenant model, IdP, resource/audience validation, registration, Origin, TLS, proxies, session persistence, rate limits, and audit retention. -- [ ] Approve or reject implementation before code exists. diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md new file mode 100644 index 0000000..7087f61 --- /dev/null +++ b/docs/MCP_SERVER.md @@ -0,0 +1,58 @@ +# MCP server + +## Current boundary + +`agent-workflow-mcp` is an optional local stdio adapter built with the pinned official Python MCP SDK. It exposes bounded read-only views of configured repository, prompt-pack, worktree, state, run, message, and receipt data. + +The adapter is intentionally not a second orchestration engine. CLI and MCP surfaces must call the same application services and produce the same durable evidence. + +Current exclusions: + +- launch or workflow mutation; +- review, acceptance, rejection, interrupt, terminate, or kill; +- arbitrary shell commands or arbitrary filesystem paths; +- raw unbounded terminal capture; +- network transport or Streamable HTTP; +- implicit privilege escalation or model selection. + +## Trust boundaries + +- Local stdio is the only authorized transport. +- Every path is resolved beneath configured roots and checked against symlink/traversal escape. +- Responses are bounded and redact sensitive fields where required. +- Actor identity uses a local server-instance identity rather than pretending to be a human reviewer. +- Resource reads do not make mutable projections authoritative. +- Optional dependency failure is explicit and actionable. + +## Planned mutation phase + +`MCP-003` is the next authorized implementation phase. It may add only validated tools that wrap existing services: + +- prompt-pack validation; +- worktree creation; +- one bounded run launch; +- workflow validate/start/status/resume; +- durable progress, acknowledgement, and steering records. + +Before any mutation tool lands, the shared service layer must provide durable idempotency keys, replay-safe result mapping, bounded request contracts, and evidence linking equivalent to the CLI. A returned tool response is not proof that a child consumed steering; correlated durable acknowledgement remains required. + +Destructive lifecycle and review/disposition tools are a later policy-gated phase. Force kill remains excluded. Streamable HTTP requires a separate authorization ADR after local stdio adoption and security evidence. + +## Planned tool/resource shape + +Read-only resources should remain URI-addressable and bounded. Mutation tools should use typed request/result schemas, stable error categories, idempotency keys, and actor provenance. Tool names must describe existing domain operations rather than expose internal Python functions. + +The active implementation pack is [`prompt-packs/mcp-server-next/`](../prompt-packs/mcp-server-next/). The canonical backlog entry is `MCP-003` in [BACKLOG.md](../BACKLOG.md). + +## Acceptance requirements + +A mutation release is not complete until black-box MCP client journeys prove: + +1. equivalent CLI and MCP operations produce equivalent durable artifacts; +2. duplicate requests do not duplicate launches or journal effects; +3. restart and reconnect preserve idempotency; +4. traversal, symlink, oversize, and unauthorized-root requests fail closed; +5. actor identity and request provenance are sealed; +6. steering remains pending until correlated acknowledgement; +7. no tool can bypass executor/model/class/no-go policy; +8. no transport beyond local stdio is enabled. diff --git a/docs/MCP_SERVER_DECISION.md b/docs/MCP_SERVER_DECISION.md deleted file mode 100644 index 6763cfd..0000000 --- a/docs/MCP_SERVER_DECISION.md +++ /dev/null @@ -1,207 +0,0 @@ -# MCP Server Architecture Decision - -**Status:** Read-only local-stdio implementation complete; safe mutation is ready for separately authorized MCP-003 work; no HTTP rollout -**Decision date:** 2026-07-24 -**Confidence:** High for local stdio; medium for later HTTP deployment - -## 1. Executive recommendation - -Adopt MCP as an optional integration layer for `agent-workflow`, beginning with a thin, local, stdio-only Python server that calls existing domain services and reads authoritative run artifacts. Do not make MCP the lifecycle authority, do not expose tmux directly, and do not add a daemon or remote HTTP endpoint in the first phase. - -The completed first surface is intentionally small: bounded read-only resources for run state/evidence plus `pack_validate`. The workflow foundation is complete through `WF-22`; the remaining mutation tools are authorized only as the separately scoped MCP-003 backlog item and must reuse the same workflow, launch, routing, messaging, approval, and receipt services as the CLI. A steering call returns `pending` unless an executor-specific correlated acknowledgement proves delivery or application. - -Rejected alternatives: - -- **No MCP server:** safest, but leaves supported MCP hosts unable to use the durable lifecycle through a standard integration boundary. -- **TypeScript rewrite:** duplicates Python lifecycle logic and adds a second implementation language without a repository need. -- **Separate local daemon first:** adds process supervision, session ownership, and authorization burden before local value is proven. -- **Direct tmux exposure:** bypasses validation and receipts and creates arbitrary-command and keystroke-injection risk. -- **Multi-host gateway now:** conflicts with the current single-host assumption and requires broker, identity, authorization, and tenancy design. - -## 2. Current architecture and durable boundaries - -`agent-workflow` is a Python CLI and domain package that creates isolated Git worktrees, launches executor processes through tmux, and stores authoritative evidence under the state root. `messages.jsonl`, immutable artifacts, lifecycle events, review receipts, and final receipts are the durable record. Terminal capture is observational context only. - -BKL-006 is complete in this change set: repo-owned skills now direct supported agents to the CLI and distinguish host-native subagents from durable runs. MCP should sit beside that skill surface as another client adapter. It must not fork lifecycle logic or reinterpret terminal output as proof. - -The MCP protocol provides initialization, version/capability negotiation, tools, resources, prompts, client capabilities, logging, cancellation/progress utilities, and HTTP authorization semantics. The current published specification is 2025-11-25; experimental Tasks exist but should not be used as the durable run authority because repository receipts already define the lifecycle contract. - -## 3. SDK, framework, and client matrix - -| Option | License / maintenance | Protocol and transports | Auth/testing ergonomics | Operational burden | Fit | -|---|---|---|---|---|---| -| Official Python SDK / FastMCP | MIT; active official repository | Client/server, tools/resources/prompts, stdio, Streamable HTTP, legacy SSE compatibility | Native Python types, async support, in-process tests, HTTP auth hooks available when needed | Lowest; matches repository language | **Selected** | -| Official TypeScript SDK | MIT; active official repository | Broad client/server coverage; Node, Bun, Deno; stdio and Streamable HTTP | Strong schemas and web ecosystem | Adds Node toolchain and duplicates Python domain calls | Good protocol option, poor repository fit | -| Official Java SDK | MIT; maintained with Spring AI | Sync/async, stdio, Streamable HTTP, HTTP/SSE; broad feature coverage | Mature testing and enterprise HTTP integrations | JVM/runtime and wrapper layer | Not justified locally | -| Official C# SDK | Apache-2.0; maintained with Microsoft | Core, hosted, and ASP.NET HTTP packages; 2025-11-25 support in stable line | Strong DI/auth integration | .NET runtime and wrapper layer | Future enterprise gateway option only | -| Official Kotlin SDK | Apache-2.0 for new contributions, legacy MIT; maintained with JetBrains | Multiplatform, stdio, Streamable HTTP, SSE, WebSocket | Coroutine and Ktor integration | New ecosystem and no repository affinity | Not justified | -| Official Ruby SDK | Official and active | stdio and Streamable HTTP/SSE, tools/resources/prompts | Conformance runner present | New runtime and smaller fit | Not justified | -| Official Go/Rust SDKs | Official and active | Systems-oriented client/server implementations | Good standalone binary potential | Requires a domain API boundary or duplicated logic | Consider only for a future hardened gateway | - -Representative hosts include Claude products, ChatGPT, Visual Studio Code, Cursor, and other MCP-compatible applications. Client feature support varies; capability negotiation is mandatory, and optional extensions must never be assumed. - -## 4. Proposed capability map - -### Read-only resources - -| Resource | Input | Output/evidence | Authorization | Notes | -|---|---|---|---|---| -| `agent-workflow://runs` | filters, bounded page cursor | sanitized run summaries from state root | local user; state-root boundary | No arbitrary path input | -| `agent-workflow://runs/{id}/status` | validated run ID | normalized status plus evidence references | same-run access | Terminal capture clearly labeled observational | -| `agent-workflow://runs/{id}/messages` | run ID, sequence cursor | durable `messages.jsonl` records | same-run access | Bounded paging | -| `agent-workflow://runs/{id}/receipts` | run ID | final/lifecycle/review receipt metadata and hashes | same-run access | No secret-bearing raw environment | -| `agent-workflow://packs/{id}` | registered/validated pack ID | manifest and validation summary | configured pack roots | No unrestricted filesystem browsing | -| `agent-workflow://workflows` | bounded filters/page cursor | sanitized workflow summaries from authoritative workflow records | local user; state-root boundary | Added only after workflow foundation completion | -| `agent-workflow://workflows/{id}/status` | validated workflow ID | allowlisted node/status summary and receipt references | same-workflow access | No raw child terminal or direct state-file exposure | -| `agent-workflow://workflows/{id}/receipts` | workflow ID | aggregate workflow receipt metadata and child receipt hashes | same-workflow access | Verify sealed hashes before presentation | - -### Mutating tools - -| Tool | Durable result | Idempotency / safety | -|---|---|---| -| `pack_validate` | validation report | read-only; pack root allowlist | -| `worktree_create` | source-baseline/worktree result | safe repo/ticket identifiers; existing collision checks | -| `launch` | run ID, prompt hash, command/provenance receipts | explicit executor allowlist; no raw shell string | -| `progress` | appended control record | caller-supplied idempotency key or returned message ID | -| `ack` | correlated acknowledgement record | requires existing message ID | -| `steer` | appended request with state `pending` | never reports delivered without correlated acknowledgement | -| `workflow_validate` | validated immutable workflow snapshot or structured validation report | configured pack/root only; no execution | -| `workflow_launch` | workflow run ID and durable snapshot/event references | idempotency key; invokes authoritative scheduler only | -| `workflow_status` / `workflow_resume` | allowlisted status or durable resume action result | valid transition, idempotency, restart-safe service | -| `interrupt` / `terminate` | lifecycle event/receipt | destructive annotation; explicit run ID; policy gate | -| `review` / `accept` / `reject` | immutable review receipts | actor/reason required; acceptance requires revision | - -### Explicit exclusions - -Do not expose arbitrary shell execution, executor argv supplied as an unvalidated string, raw `tmux send-keys`, unrestricted file reads/writes, environment dumps, secret retrieval, worktree deletion, force kill, or cross-run bulk mutation in the first phase. - -Prompts may later package operator workflows, but they are convenience templates rather than authorities. Sampling and elicitation are unnecessary for phase one. Elicitation must never be used to collect secrets. Experimental MCP Tasks must not replace repository lifecycle records. - -## 5. Transport, lifecycle, and error semantics - -### Phase-one transport - -Use stdio. The host starts one local server process with the same OS identity and filesystem permissions as the user. This avoids listening sockets, OAuth setup, DNS rebinding exposure, and service supervision. Each process initializes once, negotiates capabilities, and exits with the client. - -### Conditional HTTP evolution - -Add Streamable HTTP only after stdio conformance and real-host trials pass and maintainers authorize a service boundary. The HTTP phase requires: - -- loopback-only default binding; -- Origin and Host validation and DNS-rebinding protection; -- OAuth-based authorization following the current MCP authorization specification; -- explicit session ownership, expiration, and resumability rules; -- per-principal run and pack-root authorization; -- request size, concurrency, and rate limits; -- structured audit records linked to durable run evidence. - -Legacy HTTP/SSE should be compatibility-only, not the preferred new deployment. - -### Cancellation, progress, and errors - -MCP cancellation may stop the current RPC handler but cannot erase a durable lifecycle action already committed. Long operations should report MCP progress while recording repository-native evidence. Return stable typed errors for invalid identifiers, forbidden roots, conflict/idempotency mismatch, missing run, invalid transition, executor unavailable, and evidence-integrity failure. Never downgrade an integrity failure to a warning. - -## 6. Threat model - -| Threat | Control | -|---|---| -| Path traversal / arbitrary file access | Resolve only validated IDs under configured repository, pack, worktree, and state roots; reject symlink escapes | -| Prompt/tool injection | Treat model-provided arguments as untrusted; validate schemas and enforce server-side policy independent of descriptions | -| Confused deputy | Bind every call to the local principal/session and authorize the exact run/root/action | -| Cross-run access | Safe run IDs, root containment, and per-run authorization checks | -| tmux visibility leakage | Return bounded sanitized capture only when requested; never expose raw pane control | -| Credential leakage | Never return environment, config secrets, tokens, or executor command secrets | -| Denial of service | Bound pagination, capture length, wait timeout, concurrent launches, and request sizes | -| Destructive lifecycle misuse | Annotate destructive tools, require explicit IDs/reasons, and preserve immutable events | -| False steering claims | Return `pending`; require correlated executor acknowledgement for delivered/applied state | -| Replay/duplicate mutation | Idempotency keys mapped to durable action receipts; reject mismatched reuse | -| Evidence tampering | Verify sealed hashes before presenting authoritative completion/acceptance claims | - -## 7. Phased implementation plan - -### MCP-0 — Domain seam audit and contracts - -Identify existing CLI handlers that mix parsing with domain behavior. Define typed request/result contracts without changing lifecycle semantics. Add tests proving MCP and CLI call the same service functions. **Stop** if lifecycle behavior cannot be reused without a substantial refactor. - -### MCP-1 — Read-only local stdio server — complete - -Add `mcp==1.28.1` as an optional extra. Implement server initialization, capability declaration, run/pack resources, status, bounded messages, and receipt metadata. Add schema, traversal, redaction, conformance, and representative-host smoke tests. Rollback is removal of the optional entry point and dependency extra. - -### Workflow prerequisite gate - -`WF-22` was completed in release 0.2.0 and its scheduler, replay, receipt, and evidence authority boundaries were hardened in release 0.2.1. Restart-safe scheduling, approvals, result binding, aggregate receipts, the three authorized templates, deterministic routing explanations, and integration review are implemented. MCP-003 may proceed as a separately scoped task, but MCP must remain a façade over these services rather than becoming a second workflow implementation. - -### MCP-2 — Safe creation, workflow, and control tools - -Add `worktree_create`, single-run `launch`, workflow validate/launch/status/resume, `progress`, `ack`, and `steer` through shared services only. Require structured executor selection, configured roots, idempotency, and durable result IDs. Test invalid transitions, restart recovery, and the `pending` steering contract. - -### MCP-3 — Destructive/review tools - -Add interrupt/terminate and review/accept/reject behind explicit policy flags and tool annotations. Test authorization, reason/revision requirements, duplicate requests, and immutable receipts. Keep force kill excluded unless separately approved. - -### MCP-4 — Host matrix and release gate - -Test supported local hosts against a pinned protocol/SDK matrix. Run the official MCP Inspector/conformance tooling, cancellation/progress scenarios, malformed input fuzzing, and end-to-end receipt verification. Publish a capability compatibility table. - -### MCP-5 — Optional Streamable HTTP design gate - -Only after local adoption evidence, produce a separate ADR for service identity, OAuth, deployment, session persistence, rate limits, and multi-user authorization. No HTTP code before approval. - -## 8. Approved maintainer decisions - -1. MCP is an optional integration surface implemented in Python. -2. The first transport is local stdio, with MCP Inspector as the initial conformance client; host-specific trials follow. -3. Pin the official stable Python SDK to `mcp==1.28.1` and target protocol `2025-11-25` initially. -4. Authorize only configured repository, pack, worktree, and state roots; reject traversal and symlink escapes. -5. Omit destructive tools until MCP-3; force kill remains excluded. -6. Use `mcp-stdio:` as the local actor identity in receipts. -7. Add an optional `mcp` dependency extra and `agent-workflow-mcp` entry point; runtime code uses public SDK APIs rather than vendored internals. -8. Exclude raw terminal capture from the first surface; any later observational view must be bounded and explicitly labeled. -9. Require a separate authorization ADR before any non-stdio transport. - -## 9. Decision-refresh research and current scaffold - -Research refreshed 2026-07-24 against the official Python SDK repository and -stable tag `v1.28.1` (`777b8d06710c140e3606b0d4598e2aa48546c266`). The SDK's public -FastMCP API supports `@mcp.resource`, `@mcp.tool`, and `mcp.run(transport="stdio")`. -The repository's `main` line is v2 alpha/beta and is not the selected dependency. - -The implemented read-only adapter is in `src/agent_workflow/mcp/server.py`. It exposes bounded -run/status/messages/receipt resources and `pack_validate`, while delegating to -existing state, message, receipt, and manifest services. It does not expose shell, -tmux, arbitrary paths, launch/control mutation, raw terminal capture, or HTTP. - -The repository no longer vendors an SDK source tree. Runtime and tests use the -pinned optional `mcp==1.28.1` distribution through public APIs; dependency -provenance is recorded in `src/agent_workflow/mcp/SDK_DEPENDENCY.md`. - -## Current backlog status - -- **MCP-001 (done):** Reusable domain seams and typed MCP request/result contracts. -- **MCP-002 (done):** Read-only local stdio MCP server with official Python SDK, bounded resources, traversal/redaction controls, and conformance tests. -- **MCP-003 (ready):** Safe single-run/workflow mutation tools with idempotency and durable evidence mapping; not implemented through 0.2.1. -- **MCP-004 (deferred):** Gated destructive/review tools and representative-host evaluation matrix. -- **MCP-005 (decision):** Authorize or reject Streamable HTTP after local adoption and security evidence. - -## Official sources - -Accessed 2026-07-24; SDK source pinned to `v1.28.1`: - -- MCP specification 2025-11-25: https://modelcontextprotocol.io/specification/2025-11-25 -- Protocol overview and capability areas: https://modelcontextprotocol.io/specification/2025-06-18/basic -- Tools: https://modelcontextprotocol.io/specification/2025-06-18/server/tools -- Authorization: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization -- Elicitation security guidance: https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation -- Experimental Tasks: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks -- Architecture/client concepts: https://modelcontextprotocol.io/docs/learn/architecture -- Local server connection guidance: https://modelcontextprotocol.io/docs/develop/connect-local-servers -- Official Python SDK: https://github.com/modelcontextprotocol/python-sdk -- Official Python SDK stable tag: https://github.com/modelcontextprotocol/python-sdk/tree/v1.28.1 -- Official TypeScript SDK: https://github.com/modelcontextprotocol/typescript-sdk -- Official Java SDK: https://github.com/modelcontextprotocol/java-sdk -- Official C# SDK: https://github.com/modelcontextprotocol/csharp-sdk -- Official Kotlin SDK: https://github.com/modelcontextprotocol/kotlin-sdk -- Official Ruby SDK: https://github.com/modelcontextprotocol/ruby-sdk -- Official Go SDK: https://github.com/modelcontextprotocol/go-sdk -- Official Rust SDK: https://github.com/modelcontextprotocol/rust-sdk -- Official ecosystem introduction: https://modelcontextprotocol.io/docs/getting-started/intro diff --git a/docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md b/docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md deleted file mode 100644 index aa6c579..0000000 --- a/docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md +++ /dev/null @@ -1,339 +0,0 @@ -# MCP server implementation proposal and plan - -**Target:** post-0.2.1 mutation surface -**Current implementation:** optional, local, read-only stdio server -**Backlog authority:** `MCP-003`, `MCP-004`, and `MCP-005` in `BACKLOG.md` - -## Executive proposal - -Extend the existing MCP adapter only as a transport façade over the same application services used by the CLI. Do not create MCP-specific launch, workflow, approval, receipt, path, or message semantics. The first implementation increment (`MCP-003`) should expose safe creation and non-destructive control tools over local stdio, with idempotency records and durable result identifiers. Destructive/review actions remain a separately gated increment (`MCP-004`). Streamable HTTP remains a decision only (`MCP-005`) and requires its own authorization ADR. - -The target protocol remains MCP `2025-11-25` with the pinned optional Python SDK `mcp==1.28.1`. The SDK is not vendored; public APIs are the dependency boundary. - -## Current state - -The repository already provides: - -- `agent-workflow-mcp` stdio entry point; -- `FastMCP` resources for bounded run status, messages, and receipts; -- `pack_validate` as a read-mostly validation tool; -- configured-root containment, traversal rejection, redaction, pagination, and bounded responses; -- reusable CLI/domain services for worktrees, sessions, messages, lifecycle, workflow scheduling, result binding, approvals, and receipts; -- immutable child and aggregate workflow evidence; -- no raw shell, tmux, arbitrary path, raw terminal capture, HTTP transport, or mutation shortcut. - -## Non-negotiable invariants - -1. JSONL journals and immutable receipts remain authoritative; MCP responses are projections. -2. MCP and CLI invoke shared service functions. There is one launch path and one scheduler. -3. Paths are logical identifiers or configured-root-relative paths, never arbitrary host paths. -4. Every mutating request has an idempotency key and a durable request/result record. -5. A returned `pending` steer is not proof of delivery or application. -6. Approval tools consume and create canonical lifecycle receipts; mutable status never grants approval. -7. No-go model and executor/class policy is enforced by the existing selection path. -8. Local stdio is single-user process trust. Any network transport requires authentication, audience validation, origin checks, rate limits, and an authorization policy. - -## Target architecture - -```mermaid -flowchart LR - Host[MCP host/client] -->|stdio JSON-RPC| Adapter[FastMCP adapter] - Adapter --> Validate[Typed request validation] - Validate --> Authz[Capability and configured-root policy] - Authz --> Idem[Idempotency service] - Idem --> Facade[Shared application service façade] - Facade --> Worktree[Worktree service] - Facade --> Session[Session launch/control service] - Facade --> Workflow[WorkflowService/SchedulerService] - Facade --> Messages[Durable message service] - Facade --> Lifecycle[Lifecycle/review service] - Worktree --> Git[(Git/worktrees)] - Session --> Records[(Run records + receipts)] - Workflow --> Records - Messages --> Journals[(Fsynced JSONL)] - Lifecycle --> Records - Idem --> Requests[(MCP request/result journal)] -``` - -### Adapter layer - -The MCP layer should contain only: - -- protocol registration and annotations; -- conversion between MCP inputs and internal request dataclasses; -- bounded serialization and stable error mapping; -- cancellation/progress forwarding where the underlying operation supports it; -- actor identity injection (`mcp-stdio:`). - -It must not open subprocesses, invoke tmux, mutate Git, write status, or reconstruct workflow state directly. - -### Shared façade - -Introduce a narrow `MutationService` or equivalent composition object that is also callable by the CLI. It should delegate to existing services and return typed result envelopes with: - -- `request_id`; -- `idempotency_key`; -- `action`; -- durable result ID/path/digest; -- state (`accepted`, `pending`, `completed`, `rejected`, `failed`); -- replay indicator; -- bounded diagnostic details. - -Do not refactor stable modules merely to make the MCP code aesthetically uniform. Add seams only where CLI logic currently owns behavior that a second front end legitimately needs. - -## Proposed MCP-003 surface - -### Tools - -| Tool | Mutates | Required inputs | Durable result | -|---|---:|---|---| -| `pack_validate` | no | pack identifier/root | validation result digest | -| `worktree_create` | yes | repository ID, ticket ID, base revision, destination alias, idempotency key | worktree record and Git revision | -| `run_launch` | yes | session ID, worktree ID, prompt/pack ticket ID, class/executor/model options, idempotency key | run ID, command digest, provenance path | -| `workflow_validate` | no | snapshot or authorized template specification | normalized snapshot digest | -| `workflow_start` | yes | workflow run ID, snapshot digest, idempotency key | workflow run record and scheduled node IDs | -| `workflow_status` | no | workflow run ID | replayed workflow projection | -| `workflow_resume` | yes | workflow run ID, snapshot digest, idempotency key | replay/reconciliation result | -| `workflow_seal` | yes | workflow run ID, idempotency key | aggregate receipt digest | -| `workflow_verify` | no | workflow run ID | verification result | -| `run_progress` | yes | run ID, text, actor, idempotency key | message ID/sequence | -| `run_ack` | yes | run ID, source message ID, text, actor, idempotency key | acknowledgement message ID | -| `run_steer` | yes | run ID, text, actor, idempotency key | pending steer message ID | - -`worktree_create`, `run_launch`, and workflow tools accept logical configured-root identifiers. Direct absolute-path arguments should not be exposed to general MCP clients. - -### Resources - -Retain and expand bounded resources: - -- `agent-workflow://runs/{run_id}/status` -- `agent-workflow://runs/{run_id}/messages?after={sequence}&limit={limit}` -- `agent-workflow://runs/{run_id}/receipt` -- `agent-workflow://workflows/{workflow_id}/status` -- `agent-workflow://workflows/{workflow_id}/receipt` -- `agent-workflow://packs/{pack_id}/manifest` -- `agent-workflow://requests/{request_id}` - -Resources return typed JSON, omit secrets and raw terminal output, cap collection sizes, and provide pagination cursors rather than unbounded arrays. - -### Prompts - -Prompts are optional convenience surfaces, not authority. Candidates: - -- `prepare_delegation`: gather bounded fields for a run-launch request; -- `prepare_workflow`: select one of the three authorized templates and generate a draft canonical snapshot; -- `review_run_evidence`: guide a client through status, receipt, scope, and score resources without issuing acceptance. - -Prompt output must never execute a tool implicitly or weaken confirmation requirements. - -## Request and idempotency model - -```mermaid -sequenceDiagram - participant C as MCP client - participant A as Adapter - participant I as Idempotency service - participant S as Shared service - participant J as Durable request journal - C->>A: tool(request, idempotency_key) - A->>I: reserve(action, key, canonical request hash) - alt unseen key - I->>J: append accepted record + fsync - I->>S: execute validated operation - S-->>I: typed result + evidence refs - I->>J: append terminal/pending result + fsync - I-->>A: result replay=false - else same key and same request hash - I-->>A: prior durable result replay=true - else same key and different request hash - I-->>A: conflict error - end - A-->>C: bounded MCP result -``` - -Recommended record envelope: - -```json -{ - "schema": "agent-workflow/mcp-request-event/v1", - "request_id": "uuid", - "sequence": 1, - "idempotency_key": "client-supplied-string", - "action": "workflow_start", - "request_sha256": "…", - "actor": "mcp-stdio:instance-id", - "state": "accepted", - "created_at": "…", - "result_refs": [] -} -``` - -Use append-only events plus a reconstructable projection. Reserve keys before external effects. Replays return the recorded result. A crash after reservation but before completion is recovered by action-specific reconciliation; it is never treated as permission to repeat blindly. - -## Error contract - -Map internal errors to stable public categories while preserving a bounded operator message: - -- `invalid_request` -- `not_found` -- `conflict` -- `policy_denied` -- `unsafe_path` -- `invalid_transition` -- `evidence_invalid` -- `capacity_exceeded` -- `dependency_unavailable` -- `internal_error` - -Do not expose stack traces, command lines containing secrets, raw environment variables, or unrestricted filesystem paths. Include a request ID for local log correlation. - -## Security and authorization - -For stdio, the spawning host process is the security principal. Still enforce: - -- configured repository/pack/worktree/state roots; -- path containment after symlink resolution; -- allowlisted operations and bounded text/collection sizes; -- no raw shell command or arbitrary executable arguments; -- no implicit no-go-model override; -- no destructive/review tools in MCP-003; -- actor, reason, revision, and policy checks in MCP-004; -- response redaction and explicit secret-field denylist; -- per-request timeouts and cancellation boundaries. - -MCP guidance requires servers to validate tool inputs, implement access controls, rate-limit exposed operations, and sanitize outputs. Streamable HTTP additionally requires origin validation and authentication; authorization guidance rejects token passthrough and requires resource/audience validation. These requirements are why HTTP remains a separate decision. - -See `docs/MCP_THREAT_MODEL.md`. - -## MCP-004: destructive and review operations - -Only after MCP-003 has completed conformance, replay, and security gates, add: - -- `run_interrupt` and `run_terminate` (not force kill by default); -- `run_review`, `run_accept`, and `run_reject`; -- optional workflow approval actions that call the same lifecycle service. - -Required controls: - -- explicit feature flags per action class; -- client-side confirmation annotations plus server-side policy checks; -- non-empty actor/reason; -- exact accepted revision; -- immutable score/final-receipt validation; -- independent reviewer checks for high/critical tiers; -- idempotency and duplicate-transition tests; -- denial when evidence is stale, mutable, substituted, or belongs to another run. - -## MCP-005: conditional Streamable HTTP - -No HTTP code should be added until an ADR decides: - -- deployment and tenant model; -- identity provider and OAuth metadata; -- resource indicators/audience validation; -- client registration policy; -- session persistence and horizontal scaling; -- CSRF/origin policy; -- TLS termination and trusted proxies; -- rate limits and abuse controls; -- audit retention and data classification; -- cross-host artifact access/signing. - -Local stdio records should remain portable so the same shared services can later sit behind an authenticated transport. - -## Testing strategy - -### Unit and contract tests - -- request schema bounds and unknown-field rejection; -- canonical request hashing and key conflicts; -- stable error mapping/redaction; -- root containment, symlink, traversal, and case-normalization behavior; -- tool annotations and declared capability list; -- resource pagination/cursors; -- replay of accepted/pending/terminal request events. - -### Integration tests - -- each MCP tool compared with the equivalent CLI/shared-service result; -- duplicate request replay before and after process restart; -- crash injection around reservation, external effect, and terminal event; -- workflow start/resume with dependency failures, approvals, retries, and bindings; -- steer remains pending until correlated acknowledgement; -- no-go model and pane-cap policy are enforced through the canonical launch path; -- final and workflow receipt verification after MCP-issued operations. - -### Protocol/conformance tests - -- official MCP Inspector against the pinned SDK/protocol matrix; -- initialize/capability negotiation; -- malformed JSON-RPC and invalid tool inputs; -- cancellation/progress behavior; -- large-response bounds; -- host matrix for representative clients; -- optional dependency absent: clear startup error, core CLI unaffected. - -### Security tests - -- traversal and symlink races; -- unauthorized roots and forged logical IDs; -- secret-shaped content redaction; -- tool prompt injection cannot bypass policy; -- request-ID/idempotency collision attempts; -- lifecycle receipt substitution and mutable status forgery; -- resource enumeration limits and denial-of-service bounds. - -## Delivery phases - -### Phase A — service extraction and request journal - -- inventory CLI-only logic needed by tools; -- add minimal shared request dataclasses/result envelopes; -- implement durable MCP request event journal and replay projection; -- add canonical hashing and idempotency reservation; -- no new tools until recovery tests pass. - -### Phase B — safe creation and workflow tools - -- implement `worktree_create`, `run_launch`, workflow validate/start/status/resume/seal/verify; -- use configured logical roots and canonical services; -- verify CLI/MCP equivalence and receipt sealing. - -### Phase C — message/control tools - -- implement progress, acknowledgement, and steer; -- preserve pending/delivered/applied distinctions; -- add restart and duplicate-delivery tests. - -### Phase D — conformance and adoption gate - -- run Inspector and representative clients; -- publish capability/compatibility matrix; -- perform threat-model review and release audit; -- decide whether MCP-004 is authorized. - -### Phase E — separately authorized destructive/review tools - -- add feature-gated interrupt/terminate/review/accept/reject; -- repeat security, lifecycle, and host gates. - -### Phase F — HTTP ADR only - -- measure local adoption and identify an actual multi-process/multi-host need; -- approve or reject Streamable HTTP before implementation. - -## Migration and compatibility - -- Existing read-only resources and `pack_validate` remain compatible. -- New tools use versioned internal request schemas and additive MCP names. -- CLI output contracts remain unchanged unless the same shared result envelope is intentionally adopted and tested. -- Durable request records receive migrations through the existing contract migration policy; old records remain replayable. -- Removing the vendored SDK tree has no runtime compatibility impact because it was excluded from packaging and never imported. - -## Observability and operations - -Record structured local events for request accepted/replayed/conflict/completed/failed, action latency, bounded error category, and evidence reference. Do not log tool payloads wholesale. Expose a doctor capability that reports SDK availability, protocol target, enabled tool groups, configured roots, and request-journal health without exposing secrets. - -## Exit criteria - -MCP-003 is complete only when all safe tools use shared services, idempotency survives restart, CLI/MCP behavior matches, receipts verify, traversal/redaction/fuzz tests pass, Inspector passes, documentation/man pages are current, and no destructive or HTTP capability has slipped into scope. diff --git a/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md b/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md deleted file mode 100644 index ce6428c..0000000 --- a/docs/MCP_SERVER_IMPLEMENTATION_REPORT.md +++ /dev/null @@ -1,57 +0,0 @@ -# MCP server current implementation report - -**Release:** 0.2.1 -**Review date:** 2026-07-24 -**Scope:** implemented read-only stdio adapter and boundary verification - -## Result - -The current MCP integration is an optional local stdio adapter over bounded shared read services. It does not own lifecycle or workflow authority. The executable is `agent-workflow-mcp`; there is no `agent-workflow mcp` subcommand. - -Implemented capabilities: - -- bounded run listing and allowlisted status resources; -- durable message resources with cursors/pagination; -- receipt metadata/digest resources without arbitrary artifact reads; -- configured-root pack validation; -- traversal, symlink, missing-resource, redaction, and pagination controls; -- clear optional-dependency failure when `mcp==1.28.1` is absent. - -Not implemented: - -- worktree creation, launch, workflow mutation, progress/ack/steer tools; -- interrupt/terminate/kill, review/accept/reject; -- raw shell, arbitrary commands/paths, environment access, tmux APIs, terminal capture; -- HTTP, OAuth, multi-user authorization, or MCP task lifecycle. - -## Dependency and protocol boundary - -The target remains MCP specification `2025-11-25` with optional Python SDK `mcp==1.28.1`. Runtime code uses public `mcp.server.fastmcp.FastMCP` APIs. The unused 141-file vendored SDK source snapshot was removed in 0.2.0; dependency provenance remains in `src/agent_workflow/mcp/SDK_DEPENDENCY.md`. - -Primary sources reviewed on 2026-07-24: - -- https://modelcontextprotocol.io/specification/2025-11-25 -- https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization -- https://modelcontextprotocol.io/specification/2025-11-25/basic/transports -- https://modelcontextprotocol.io/specification/2025-06-18/server/tools -- https://github.com/modelcontextprotocol/python-sdk/tree/v1.28.1 -- https://modelcontextprotocol.io/docs/tools/inspector - -## Shared-service boundary - -`src/agent_workflow/mcp/server.py` registers protocol surfaces. `src/agent_workflow/mcp/services.py` performs bounded identifier/path validation and invokes existing read authorities. The adapter does not start processes, call tmux, write session/workflow state, infer delivery, or generate lifecycle receipts. - -## Workflow sequencing status - -WF-22 was completed in 0.2.0 and its workflow authority/replay boundaries were hardened in 0.2.1. The workflow scheduler, approval gates, result binding, aggregate receipts, templates, routing advice, and provider evidence are now stable prerequisites. Therefore `MCP-003` has moved from blocked to ready, but no mutation capability was added in this release. - -The next implementation must follow [MCP Server Implementation Proposal](MCP_SERVER_IMPLEMENTATION_PROPOSAL.md): durable idempotency first, shared application services only, safe creation/workflow/message tools before destructive/review tools, and no HTTP without a separate authorization ADR. - -## Verification surfaces - -- `tests/test_mcp_services.py` -- `tests/test_mcp_server.py` -- `tests/test_optional_integrations.py` -- full repository suite and release audit recorded in `docs/execution-evidence/FINAL_CRITICAL_REVIEW.md` - -MCP Inspector and representative external host checks remain environment-dependent gates for the future mutation release. They are not claimed as completed by this local source pass. diff --git a/docs/MCP_THREAT_MODEL.md b/docs/MCP_THREAT_MODEL.md deleted file mode 100644 index bdcc8bf..0000000 --- a/docs/MCP_THREAT_MODEL.md +++ /dev/null @@ -1,84 +0,0 @@ -# MCP server threat model - -## Assets - -- repository and worktree contents; -- prompt packs and source baselines; -- executor/model/permission policy; -- tmux processes and child agents; -- durable message journals and cursors; -- run artifacts, provider evidence, scores, and receipts; -- lifecycle approvals and accepted revisions; -- local configuration and credentials present in the environment. - -## Trust boundaries - -```mermaid -flowchart TB - subgraph Client[Untrusted/partially trusted MCP client content] - Prompt[Prompt text] - Args[Tool arguments] - end - subgraph Server[Local MCP server process] - SDK[Protocol SDK] - Validation[Schema/bounds validation] - Policy[Authorization + configured roots] - Services[Shared application services] - end - subgraph Host[Trusted local host boundary] - Config[User config] - Git[Repositories/worktrees] - State[Durable state/receipts] - Tmux[tmux/executors] - end - Prompt --> SDK - Args --> SDK - SDK --> Validation --> Policy --> Services - Services --> Config - Services --> Git - Services --> State - Services --> Tmux -``` - -For stdio, the process that launches the server is the authenticated principal. Tool content remains untrusted. A future network transport creates a new identity and tenant boundary and is not covered by stdio assumptions. - -## Threats and mitigations - -| Threat | Example | Required mitigation | -|---|---|---| -| Path traversal | `../../.ssh` or symlinked pack | configured roots, resolved containment, regular-file checks, no arbitrary path tools | -| Command injection | prompt asks tool to append shell flags | no raw command tool; structured executor selection through canonical launch policy | -| Prompt injection | repository text asks client to accept a run | prompts cannot execute tools; server revalidates every request and lifecycle condition | -| Duplicate external effect | client retries `run_launch` after timeout | reserve idempotency key before effect; same key/hash replays; conflict on different hash | -| Crash ambiguity | server dies after worktree created | durable accepted event and action-specific reconciliation before retry | -| Receipt forgery | mutate `status.json` to point at accepted receipt | reconstruct canonical read-only receipt chain and verify sealed artifact digests | -| Artifact substitution | replace child final receipt/result | regular non-symlink read-only receipts, SHA-256 verification, aggregate workflow receipt | -| Policy bypass | request no-go model or interactive mode | canonical class/executor/model/no-go enforcement; no adapter-local override | -| Secret exfiltration | status/error includes token or environment | allowlisted response fields, redaction, no raw environment or terminal output | -| Denial of service | huge message/resource request | strict byte/item/page limits, timeout, cancellation, rate limits for network transport | -| Confused deputy | one client references another root/run | logical IDs resolved within configured authorized roots and future tenant scope | -| Token passthrough | HTTP server forwards client token downstream | prohibited; validate token audience/resource and use separate downstream credentials | -| DNS rebinding/CSRF | malicious browser hits local HTTP server | no HTTP in current scope; future Streamable HTTP must validate Origin and authenticate | -| Lifecycle abuse | accept without independent review | existing tier, score, review-chain, actor, reason, revision, and immutable receipt checks | -| Unsafe cancellation | client cancellation leaves half-written record | append/fync accepted record first; atomic files; terminal/recovery event after reconciliation | - -## Security test matrix - -- encoded and mixed-separator traversal; -- symlink root, leaf, and time-of-check/time-of-use replacement; -- unknown logical IDs and cross-root aliases; -- oversized strings, arrays, pages, and malformed JSON-RPC; -- duplicate idempotency key with same/different canonical payload; -- crash injection at every mutation boundary; -- stale/mutable/substituted lifecycle and final receipts; -- forged snapshot and workflow event sequence; -- no-go model and permission-argument bypass attempts; -- secret-shaped values in status, errors, prompts, and receipts; -- cancellation before reservation, after reservation, after effect, and after terminal record. - -## Residual risks - -- A trusted local user who can modify the same state files can still destroy evidence; read-only modes and hashes detect but do not prevent privileged tampering. -- Executor CLIs and tmux are external processes. Their availability and version behavior require doctor checks and provenance. -- Stdio clients inherit the permissions of the server process. Least-privilege OS execution remains an operational responsibility. -- Multi-user/network deployment requires a new authorization model, not reuse of local path trust. diff --git a/docs/MCP_TOOL_AND_RESOURCE_CATALOG.md b/docs/MCP_TOOL_AND_RESOURCE_CATALOG.md deleted file mode 100644 index 82c6619..0000000 --- a/docs/MCP_TOOL_AND_RESOURCE_CATALOG.md +++ /dev/null @@ -1,73 +0,0 @@ -# MCP tool, resource, and prompt catalog - -This catalog defines the proposed public surface. `Current` means implemented in the read-only stdio adapter; `MCP-003` and later rows are plans, not present capabilities. - -## Tools - -| Name | Phase | Mutating | Confirmation | Authority/service | Result state | -|---|---|---:|---:|---|---| -| `pack_validate` | Current | no | no | pack validator | completed/failed | -| `worktree_create` | MCP-003 | yes | yes | worktree service | completed/replayed/failed | -| `run_launch` | MCP-003 | yes | yes | canonical session launch | accepted/completed/replayed/failed | -| `workflow_validate` | MCP-003 | no | no | `WorkflowService.validate` | completed/failed | -| `workflow_start` | MCP-003 | yes | yes | `WorkflowService.start` | accepted/completed/replayed/failed | -| `workflow_status` | MCP-003 | no | no | workflow replay | completed/failed | -| `workflow_resume` | MCP-003 | yes | yes | `WorkflowService.resume` | completed/replayed/failed | -| `workflow_seal` | MCP-003 | yes | yes | aggregate receipt service | completed/replayed/failed | -| `workflow_verify` | MCP-003 | no | no | aggregate receipt verifier | completed/failed | -| `run_progress` | MCP-003 | yes | no | durable messages | completed/replayed/failed | -| `run_ack` | MCP-003 | yes | no | durable messages | completed/replayed/failed | -| `run_steer` | MCP-003 | yes | yes | durable messages | pending/replayed/failed | -| `run_interrupt` | MCP-004 | yes | yes | session control | completed/replayed/failed | -| `run_terminate` | MCP-004 | yes | yes | session control | completed/replayed/failed | -| `run_review` | MCP-004 | yes | yes | lifecycle service | completed/replayed/failed | -| `run_accept` | MCP-004 | yes | yes | lifecycle service | completed/replayed/failed | -| `run_reject` | MCP-004 | yes | yes | lifecycle service | completed/replayed/failed | - -Excluded: raw shell, arbitrary command execution, arbitrary file read/write, force kill, merge, raw terminal capture, direct tmux APIs, model-policy bypass, and HTTP administration. - -## Resources - -| URI | Status | Bound | -|---|---|---| -| `agent-workflow://runs` | Current | paginated allowlisted summaries | -| `agent-workflow://runs/{run_id}/status` | Current | redacted typed status | -| `agent-workflow://runs/{run_id}/messages` | Current | sequence cursor and limit | -| `agent-workflow://runs/{run_id}/receipt` | Current | final receipt metadata, not arbitrary artifacts | -| `agent-workflow://workflows/{workflow_id}/status` | MCP-003 | replayed projection | -| `agent-workflow://workflows/{workflow_id}/receipt` | MCP-003 | aggregate receipt metadata | -| `agent-workflow://packs/{pack_id}/manifest` | MCP-003 | configured pack root only | -| `agent-workflow://requests/{request_id}` | MCP-003 | durable idempotency/result projection | - -## Prompts - -| Prompt | Phase | Purpose | -|---|---|---| -| `prepare_delegation` | MCP-003 optional | Produce a validated draft launch request; never launches implicitly. | -| `prepare_workflow` | MCP-003 optional | Produce one authorized template specification/canonical snapshot draft. | -| `review_run_evidence` | MCP-004 optional | Guide inspection of receipts, scope, commands, scores, and lifecycle evidence; never accepts implicitly. | - -## Shared input rules - -- IDs use the repository ID validator and bounded lengths. -- Text inputs are UTF-8, non-empty where required, and capped. -- Paths are configured-root logical references; traversal and symlinks are rejected. -- Unknown fields are rejected. -- Mutations require an idempotency key. -- Executor/model/class fields pass through canonical policy enforcement. -- Review/accept fields require actor, reason, and exact revision as applicable. - -## Stable result envelope - -```json -{ - "schema": "agent-workflow/mcp-result/v1", - "request_id": "uuid", - "action": "run_steer", - "state": "pending", - "replayed": false, - "result_id": "message-uuid", - "evidence": [{"kind": "message", "id": "message-uuid", "sha256": "…"}], - "error": null -} -``` diff --git a/docs/MESSAGING_PRIOR_ART_REPORT.md b/docs/MESSAGING_PRIOR_ART_REPORT.md deleted file mode 100644 index 8d0c22c..0000000 --- a/docs/MESSAGING_PRIOR_ART_REPORT.md +++ /dev/null @@ -1,71 +0,0 @@ -# Messaging Prior Art and Local Control Contract - -## Decision - -Use the run directory as the sole authority, with an append-only JSONL log protected by -an advisory file lock and `fsync`. A waiter always replays durable records. `tmux wait-for` -may later be used only as a latency accelerator; it cannot become the source of truth. -The message UUID is the idempotency identity, sequence is contiguous within a run, and a -steer remains pending until a child appends a correlated acknowledgement after applying it. - -## Decision matrix - -| Option | Durable replay | Multiwriter behavior | Operational cost | Fit | -|---|---|---|---|---| -| Filesystem JSONL + `flock`/`fsync` | Yes, by replay | Serialized local writers | None beyond the CLI | **Selected** | -| `tmux wait-for` / control mode | No durable history by itself | Synchronization only | Already available | Wakeup accelerator only | -| SQLite WAL | Yes | Strong transactions, one writer at a time | Schema/database lifecycle | Unnecessary for one small log | -| Redis Streams | Yes, consumer groups and acknowledgements | Server-mediated | Requires a service | Reject for local-only CLI | -| NATS JetStream | Yes, durable consumers and explicit acknowledgements | Server-mediated | Requires a service | Reject for local-only CLI | -| Temporal Signals/Updates | Workflow-history durability | Service-mediated | Requires Temporal | Reject for local-only CLI | - -## Frozen contract - -- **Authority:** `/messages.jsonl`; lifecycle `events.jsonl` remains separate. -- **Wakeup:** optional `tmux wait-for` signal. After every wakeup, replay from the last - accepted sequence. Lost or coalesced wakeups therefore do not lose records. -- **Identity:** canonical UUID `message_id`; consumers deduplicate by UUID. -- **Ordering:** writers hold an exclusive lock, validate the complete existing log, - allocate the next contiguous sequence, append one line, flush, and `fsync`. -- **Acknowledgement:** only a child-to-parent `ack` may correlate to an existing - parent-to-child `steer`; duplicate acknowledgements are rejected. -- **Retention:** records are bounded per run and sealed with the run. Compaction is not - performed while active. -- **Recovery:** restart replays from sequence zero or the consumer's last durable cursor. - A malformed or partial tail blocks further append rather than silently truncating data. - -## Delivery boundary - -`tmux send-keys` injects terminal input. It does not prove that an opaque one-shot executor -accepted, parsed, applied, or semantically associated that input with a steering request. -A genuine live adapter must name the executor capability, submit through its supported -control interface, and append an acknowledgement containing the request UUID only after -application. Where stdin closes after the initial prompt, the safe state is durable and -pending—not “delivered.” - -## Threat model and mitigations - -| Threat | Mitigation | -|---|---| -| Traversal or symlink redirection | Fixed filename, validated session IDs, real run directory, regular non-symlink log | -| Concurrent writers | `flock(LOCK_EX)` around validation, sequence allocation, append, flush, and `fsync` | -| Partial write or corrupt tail | Strict line-by-line JSON and contiguous-sequence validation; reject append on corruption | -| Duplicate delivery | UUID idempotency key and explicit correlated acknowledgement | -| Oversized or malformed payload | Bounded content and closed direction/kind/field sets | -| False local authority | Disk replay is authoritative; wakeups and terminal output are advisory | - -## Primary references - -- tmux manual and source: `https://github.com/tmux/tmux/blob/master/tmux.1` -- tmux control mode: `https://github.com/tmux/tmux/wiki/Control-Mode` -- SQLite WAL: `https://www.sqlite.org/wal.html` -- Redis Streams: `https://redis.io/docs/latest/develop/data-types/streams/` -- NATS JetStream consumers: `https://docs.nats.io/nats-concepts/jetstream/consumers` -- Temporal message passing: `https://docs.temporal.io/develop/python/message-passing` - -The service-backed options offer valuable cross-host consumer state, ACLs, replication, -and server-managed redelivery, but those properties do not justify introducing a daemon -or network dependency for the current single-host CLI. Redis and JetStream explicitly use -pending/acknowledgement models, reinforcing the selected at-least-once plus idempotency -contract. SQLite WAL adds transactional storage but also a database schema and checkpoint -lifecycle that the bounded append-only control stream does not need. diff --git a/docs/MIGRATING_EXISTING_ASSETS.md b/docs/MIGRATING_EXISTING_ASSETS.md deleted file mode 100644 index 8fb1f8a..0000000 --- a/docs/MIGRATING_EXISTING_ASSETS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Migrating Existing Workflow Assets - -The original durable set consisted of: - -```text -scripts/ -templates/ -EXECUTION_PROTOCOL.md -DELEGATION_RUNBOOK.md -``` - -This repository incorporates and supersedes those assets. - -## Procedure - -1. Extract this repository into its permanent source location. -2. Compare any local edits to the versions here. -3. Preserve project-specific edits inside the relevant project prompt pack. -4. Install this repository with `./install.sh`. -5. Stop copying the old scripts independently; use this CLI or its compatibility wrappers. -6. Keep project-specific phases, tickets, source excerpts, and code outlines out of the global repository. - -## Ownership after migration - -- lifecycle behavior: `src/agent_workflow/`; -- human policy: root protocol/runbook and `docs/`; -- reusable forms: `templates/`; -- agent-discoverable instructions: `skills/`; -- old helper names: thin wrappers under `scripts/`. diff --git a/docs/MODEL_TIERS.md b/docs/MODEL_TIERS.md deleted file mode 100644 index 6037c12..0000000 --- a/docs/MODEL_TIERS.md +++ /dev/null @@ -1,22 +0,0 @@ -# Model and Delegation Tiers - -The tiers describe reasoning and risk requirements, not product-specific model names. - -## Tier A - -Use for security-sensitive changes, data migrations, authority-boundary changes, cross-repository contracts, destructive cleanup, and independent final review. - -## Tier B - -Use for bounded production implementation with explicit interfaces, paths, and acceptance tests. A stronger reviewer is appropriate when the change affects shared contracts. - -## Tier C - -Use for deterministic inventory, documentation truth corrections, manifest generation, simple wrappers, and read-only preflight work. The prompt must still contain exact paths and stop conditions. - -## Composite labels - -- `B_with_A_gate`: bounded implementation with a high-reasoning reviewer. -- `A_reviewer`: independent review only. - -Do not assign a smaller model to “figure out the architecture.” Ownership decisions and code shapes belong in pack references before delegation. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..b9eb544 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,107 @@ +# Operations + +This document consolidates day-to-day delegation lifecycle, agent policy, recovery, and host-routing guidance. + +## Execution model + +Each delegation has: + +- a source repository and pinned baseline revision; +- a dedicated Git worktree; +- a fresh session ID and durable run directory; +- a copied and hashed prompt; +- a configured executor/model/class selection or explicit argv; +- a tmux session or pane used as an observation and process-control surface; +- sealed terminal evidence and later immutable lifecycle receipts. + +The CLI and MCP adapter call shared services. Shell scripts are compatibility wrappers only. + +## Launch policy + +Agent classes constrain interactivity, executor, and model combinations. Named profiles may narrow those choices but cannot escape class policy. Explicit no-go authorization is recorded in provenance. + +Routing advice is deterministic and explainable, but advisory. The launch plan produced by configured policy is authoritative. Never allow a recommendation, prior trial, or agent-selected value to bypass class, executor, model, or permission restrictions. + +The default classes are: + +| Class | Intended use | Default behavior | +|---|---|---| +| `exploratory` | bounded research and reconnaissance | non-interactive, low-cost models | +| `review` | independent inspection and evidence review | non-interactive | +| `implementation` | code-changing work | interactive by default | + +The concrete allowlists live in configuration, not documentation. Use `agent-workflow config show` to inspect effective policy. + +## Observe and control + +```bash +agent-workflow list +agent-workflow status SESSION --capture 50 +agent-workflow attach SESSION +agent-workflow tail SESSION +``` + +Controls preserve prior evidence: + +```bash +agent-workflow interrupt SESSION +agent-workflow terminate SESSION +agent-workflow kill SESSION +agent-workflow restart SESSION --new-session RETRY_SESSION +``` + +A potential stall is a diagnostic state, not authorization to terminate. Inspect terminal liveness, heartbeat, lifecycle events, log movement, and durable messages before acting. + +## Durable messages + +The fsynced message journal is the authority. tmux `wait-for` is a local wakeup accelerator only. Producers append immutable records; consumers replay by sequence and acknowledge work explicitly. + +```bash +agent-workflow steer SESSION "Run the focused tests." --actor orchestrator +agent-workflow watch SESSION --after 0 --timeout 300 +agent-workflow progress SESSION "Focused tests passed." --actor child +agent-workflow ack SESSION MESSAGE_ID "Applied." --actor child +``` + +A steer is pending until correlated acknowledgement exists. Logs, terminal text, or a live tmux process do not prove delivery or application. + +## Interactive agent reuse + +Interactive agents can retain bounded assignment context. Reuse requires: + +- explicit prior task completion; +- an idle, live, unexpired session; +- the exact same worktree; +- a compatible agent policy; +- exact ticket/retry lineage for automatic reuse; +- correlated acknowledgement of the reassignment. + +```bash +agent-workflow agent task-complete SESSION --actor AGENT --summary "Implemented parser" +agent-workflow agent candidates /path/to/worktree --ticket TICKET --pack PACK +agent-workflow agent reuse SESSION ./next-task.md --actor orchestrator --ticket TICKET --pack PACK +``` + +Similarity ranking helps an operator choose; it is not autonomous memory or routing authority. + +## Workflow recovery + +Workflow snapshots are immutable and event journals append-only. To recover after interruption: + +1. verify the supplied snapshot is identical to the started snapshot; +2. run `workflow status` to refresh projections from the journal; +3. inspect child run evidence for running or terminal nodes; +4. run `workflow resume` to reconcile and schedule eligible work; +5. seal only after every node is terminal. + +Never edit `workflow-status.json` to repair state. It is a projection. + +## Worktree and evidence cleanup + +Do not delete a failed worktree or run directory automatically. Review the patch and receipts first. Removing a worktree does not remove the authoritative XDG run evidence. + +Use `worktree remove` only after deciding whether the branch should be retained. Use uninstall only for installer-owned links and launchers; unrelated paths are left untouched. + +## Host routing + +Global instructions may recommend `agent-workflow` for bounded delegation. They are guidance, not a security boundary. Future installer-owned hooks may block a narrowly defined set of direct delegation commands, but only after explicit maintainer authorization and an audited break-glass path. See `BKL-007` in [BACKLOG.md](../BACKLOG.md). diff --git a/docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md b/docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md deleted file mode 100644 index 4528b92..0000000 --- a/docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md +++ /dev/null @@ -1,202 +0,0 @@ -# Orchestrator Messaging and Regression Evals - -**Task tracking:** [BACKLOG.md](../BACKLOG.md) is the canonical register for -unfinished work. This document records architecture, contracts, and status. - -## Outcome - -Make local tmux-backed delegations observable and steerable without a daemon or -network message bus, then establish two small receipt-backed regression evals: - -1. a deterministic JSON-normalization task; and -2. a pinned-browser priority-picker task that proves explicit parent/child - communication as well as visual and accessibility behavior. - -## Decision - -The run directory remains the authority. Add append-only, fsync'd control -records and use `tmux wait-for` only as a best-effort wakeup. A waiter always -replays durable records after waking. This avoids lost updates if tmux, a -watcher, or an orchestrator restarts. - -Do not add Redis, NATS, Temporal, a daemon, a database, or a generic plugin -system for this single-host CLI. Those become candidates only when runs need -cross-host consumers, independently durable subscriptions, or multi-user ACLs. - -`tmux wait-for` is documented in the [tmux manual](https://github.com/tmux/tmux/blob/master/tmux.1); filesystem wakeup behavior and loss/coalescing limits are documented by [inotify](https://man7.org/linux/man-pages/man7/inotify.7.html). Redis Streams and Temporal signals are research comparators, not dependencies: [Redis Streams](https://redis.io/docs/latest/develop/data-types/streams/) and [Temporal message passing](https://docs.temporal.io/develop/python/message-passing). - -## Contracts - -### Control record - -`messages.jsonl` is append-only while a run is active. Every record has a -versioned schema, contiguous `sequence`, UUID `message_id`, session ID, UTC -timestamp, `parent_to_child` or `child_to_parent` direction, `steer`, -`progress`, `ack`, or `error` kind, bounded actor/content, and an optional -correlation ID. - -Writers lock, append one complete line, flush, and fsync. Records are capped, -validated, and replayed in contiguous sequence order. A command is at-least -once; consumers deduplicate by `message_id` and append an `ack` only after -applying its effect. - -### Delivery boundary - -The CLI supports child progress and parent steering records plus a blocking -`watch` view. It cannot promise that an arbitrary one-shot executor consumes a -prompt after its initial stdin closes. Executor-specific live injection is a -later adapter with an acknowledgement contract; terminal keystrokes are not -semantic delivery evidence. - -### Late-steering adapter implementation ticket - -`BKL-002` records the remaining runtime gap. A live July 2026 detached Codex -run demonstrated the current boundary: `agent-workflow steer` could append a -durable request, but the one-shot `codex exec` process had no supported input -channel through which the running model could consume and acknowledge it. -Captured output, process liveness, and log growth remained observational only. - -Implementation starts with a source-backed capability matrix for the installed -Codex and Claude executors. Select a documented machine interface for Codex if -one supports post-launch input and correlated responses; document Claude's -equivalent interface and limitations in the same matrix. If neither installed -executor exposes such a contract, finish the research report and leave the -ticket blocked rather than injecting terminal keystrokes or parsing model prose -as acknowledgement. - -The first supported adapter must: - -- preserve `messages.jsonl` as the authoritative request and acknowledgement - record, with a per-consumer cursor and idempotent replay; -- consume a steer after launch without restarting the delegated task, including - a request for the child to report current progress; -- distinguish durable request acceptance, executor delivery, child application - or rejection, and terminal non-delivery with correlated sealed evidence; -- recover after orchestrator or adapter restart without losing or applying a - message twice; -- report an unavailable or unsupported adapter explicitly while leaving the - steer pending; and -- add fake-adapter contract tests plus an opt-in real-executor smoke test that - proves the running child received the exact bounded request and responded - through `progress` and correlated `ack` records. - -The adapter belongs behind the existing session and message services. It must -not create a second launcher, executor path, mutable authority, or MCP-only -delivery implementation. MCP may expose pending steering independently; only -this adapter's correlated evidence can advance it to delivered or applied. - -### Sealed evaluation evidence - -Before sealing, write `execution-metrics.json` and `control-events.jsonl`. -Metrics include nullable input/cached-input/output/provider-total/cost/currency, -monotonic stage duration, first-output latency, retries, steering outcomes, and -normalized errors. Absence is `null`, never zero. Raw executor streams remain -the primary provider evidence. - -## Phases - -| Phase | Scope | Exit gate | -|---|---|---| -| 0 | Research and design decision | Source-backed contract matrix; no runtime edits. | -| 1 | Durable local messaging | Validated log, progress, steer, replay-based wait, unit tests. | -| 2 | Deterministic eval and metrics | Sealed metrics/control artifacts; normalization fixture and stable report. | -| 3 | Visual orchestration eval | Pinned browser fixture, explicit child telemetry, receipt bridge. | - -## Current implementation status - -Phase 1 is implemented and hardened: `messages.jsonl` uses a versioned, -validated, locked and fsync'd record format. It rejects unsafe targets, mixed -session records, duplicate IDs, invalid direction/kind pairs, and duplicate or -out-of-order acknowledgements. The CLI exposes `steer`, `progress`, `ack`, and -blocking `watch`; a request is durable and observable but is not represented as -delivered until a correlated acknowledgement exists. - -Phase 2 is implemented for native runs: `control-events.jsonl` and -`execution-metrics.json` are atomically written, schema-validated, sealed, -made read-only, and included in reports. Metrics expose nullable normalized -provider usage, orchestration/child/verification/total stages, steer state, -and errors. The deterministic normalization fixture has hidden-contract, -mutation, oracle-canary, and repeatability coverage. - -The generic executor adapter remains intentionally absent: current runners -write an initial prompt to one-shot stdin and close it, so they cannot prove -late semantic delivery. Phase 3 is correctly blocked: no pinned browser image -digest/font manifest or verified pre-seal browser/Inspect evidence bridge is -available. See `docs/PHASE_3_BLOCKED_GATE_REPORT.md`. - -## Evaluation fixtures - -### Deterministic JSON normalization - -Fixture layout: - -```text -tests/fixtures/regression-evals/deterministic-json/ - app/normalize.py - tests/test_normalize.py - tests/fixtures/input.json - tests/fixtures/expected.json - prompt.md - evaluation.json -``` - -The task is `normalize_records(records)`: canonical ordering, duplicate merge, -UTC timestamp normalization, invalid-row rejection, and stable JSON output. -Known plus evaluator-only hidden cases and deliberate mutations independently -cover ordering, duplicate merge, timezone, invalid input, and formatting. -Functional verdicts are exact exit/test/output facts, never an LLM judgment. - -### Visual priority picker - -Fixture layout: - -```text -tests/fixtures/regression-evals/ui-child-handoff/ - app/index.html - app/app.js - app/style.css - tests/ui.spec.ts - Dockerfile - prompt.md - evaluation.json -``` - -The UI opens a compact priority palette; choosing `Urgent` changes the badge -and accessible label; Escape closes the palette without changing selection. -Pin a 1280x720 viewport, device scale, browser, fonts, and animation timing. -Use Playwright click/keyboard/ARIA assertions and an evaluator-held screenshot -baseline. Explicit telemetry must include `child_started`, request/result -digests, `child_finished`, stage, child ID, monotonic time, and errors. - -## Acceptance criteria - -- A parent blocks on a session update instead of polling; restart/replay does - not lose a message. -- A child emits progress; a parent persists a steer request and observes its - acknowledgement or non-delivery state without killing the session. -- Every eval run retains stage and total usage/cost/time, retry/error, and - steering summaries, plus raw sealed evidence. -- The deterministic fixture has known and evaluator-only hidden cases and a - repeatable exact verdict. -- The UI fixture pins browser/font/viewport/animation behavior and checks DOM, - keyboard, accessibility, and screenshot evidence. It fails if required child - telemetry is absent, out of order, or unacknowledged. - -## Non-goals and stop conditions - -- Do not infer progress or delegation from model prose. -- Do not auto-accept a ticket from terminal exit, a message, or an eval pass. -- Do not persist secrets/raw prompts in control records. -- Stop a phase if an executor adapter, pinned browser image, or source contract - is unavailable; record the blocker rather than inventing a transport. - -## Current State vs Long-Term Goal - -| Area | State | Next gap | -|---|---|---| -| Durable control records | Implemented | Follow-on durable cursors and idempotent handling: [BKL-001](../BACKLOG.md#now). | -| Mid-task steering | Partially implemented | Executor-specific semantic steering: [BKL-002](../BACKLOG.md#now). | -| Token/cost/time evidence | Partially implemented | Provider calibration and evidence completion: [BKL-003](../BACKLOG.md#now) and [BKL-005](../BACKLOG.md#now). | -| Deterministic eval | Implemented as fixture/regression coverage | Controlled real-executor cohort: [BKL-004](../BACKLOG.md#now). | -| Visual eval | Correctly blocked | External prerequisites and implementation gate: [BKL-010](../BACKLOG.md#blocked-on-supplied-prerequisites). | -| Multi-agent orchestration | Foundation only | Durable lifecycle protocol begins with [BKL-001](../BACKLOG.md#now); distributed transport requires [DEC-003](../BACKLOG.md#decisions-required-before-implementation). | diff --git a/docs/PHASE_3_BLOCKED_GATE_REPORT.md b/docs/PHASE_3_BLOCKED_GATE_REPORT.md deleted file mode 100644 index d687e4f..0000000 --- a/docs/PHASE_3_BLOCKED_GATE_REPORT.md +++ /dev/null @@ -1,20 +0,0 @@ -# Phase 3 blocked gate report - -**Canonical task:** [BKL-010 in BACKLOG.md](../BACKLOG.md#blocked-on-supplied-prerequisites). - -## Verdict - -Blocked by the ticket's explicit stop condition. - -## Verified missing prerequisites - -- No authoritative pinned browser container digest is supplied by the prompt pack. -- No pinned font manifest is supplied. -- The current run receipt has no verified browser/Inspect import bridge contract. -- Therefore screenshot reproducibility and pre-seal browser evidence cannot be claimed. - -## Work preserved - -`tests/fixtures/regression-evals/priority-picker/README.md` freezes the telemetry order, -primary DOM/ARIA judging rule, screenshot role, and required negative cases. No unpinned -browser dependency or misleading “visual eval complete” claim was added. diff --git a/docs/PROMPT_PACKS.md b/docs/PROMPT_PACKS.md new file mode 100644 index 0000000..341ec15 --- /dev/null +++ b/docs/PROMPT_PACKS.md @@ -0,0 +1,76 @@ +# Prompt packs + +A prompt pack is a portable, reviewable execution plan for bounded delegated work. It contains phase manifests, ticket prompts, execution rules, evidence templates, and deterministic checksums. + +## Canonical structure + +```text +pack/ +├── README.md +├── EXECUTION_PROTOCOL.md +├── DELEGATION_RUNBOOK.md +├── pack.yaml +├── MANIFEST.sha256 +├── phase-0/ +│ ├── README.md +│ ├── MASTER_IMPLEMENTATION_PROMPT.md +│ ├── task-manifest.yaml +│ └── tickets/ +├── references/ +├── scripts/ +└── templates/ +``` + +Create and validate a pack through the public CLI: + +```bash +agent-workflow pack scaffold ./pack --phases 3 +agent-workflow pack validate ./pack +agent-workflow pack archive ./pack ./pack.tar.zst +``` + +## Task manifests + +Every task declares a stable ticket ID, tier, session ID, and prompt path. Dependencies may cross phases but must form one valid DAG. Unknown, duplicate, self, and cyclic dependencies are rejected. + +A ticket may declare a structured result contract: + +```yaml +result_contract: + schema: contracts/task-result.schema.json + required: true +``` + +The agent writes `result.json` to its handoff directory. The runner validates it against the pack-owned JSON Schema, records a collection receipt, and seals the validated copy with the run. Workflow bindings may read only bounded JSON Pointer values from sealed predecessor results. + +## Prompt requirements + +A ticket prompt should state: + +- the writable scope; +- the required behavior and non-targets; +- acceptance commands or observable outcomes; +- expected evidence and completion handoff; +- stop conditions and unresolved prerequisites. + +Do not embed host-specific absolute paths, credentials, private project names, mutable external documents, or assumptions that are not represented in the manifest or backlog. + +## Execution authority + +The pack describes work; it does not override runtime policy. Agent class, executor, model, permissions, no-go authorization, worktree safety, and lifecycle controls remain enforced by `agent-workflow` configuration and services. + +`EXECUTION_PROTOCOL.md` and `DELEGATION_RUNBOOK.md` are canonical portable files mirrored into scaffold assets. Update their canonical root copies first and run the release audit to detect drift. + +## Migration and maintenance + +For an existing set of prompts: + +1. scaffold a new pack; +2. assign stable ticket and session IDs; +3. move each prompt into one phase ticket; +4. express dependencies in manifests rather than prose order; +5. move shared background into bounded references; +6. add structured result contracts only where downstream automation needs them; +7. validate, review warnings, and archive deterministically. + +Completed one-off prompt packs should not remain in the public source tree as permanent documentation. Git history and the changelog preserve implementation history. Retain only packs that are active development inputs; the current active pack is `prompt-packs/mcp-server-next/`. diff --git a/docs/PROMPT_PACK_STANDARD.md b/docs/PROMPT_PACK_STANDARD.md deleted file mode 100644 index 9c2f181..0000000 --- a/docs/PROMPT_PACK_STANDARD.md +++ /dev/null @@ -1,53 +0,0 @@ -# Prompt-Pack Standard - -A prompt pack is a self-contained implementation plan intended to remain usable when delegated to smaller models. - -## Required root files - -```text -README.md -EXECUTION_PROTOCOL.md -DELEGATION_RUNBOOK.md -templates/TICKET_COMPLETION.md -templates/PHASE_GATE_REPORT.md -templates/source-baseline.example.json -``` - -## Required phase structure - -```text -phase-N/ -├── README.md -├── MASTER_IMPLEMENTATION_PROMPT.md -├── task-manifest.yaml -└── tickets/ - └── PN-XX-description.md -``` - -## Ticket minimum - -Every implementation ticket states: - -- objective and non-goals; -- recommended model/delegation tier; -- dependencies; -- required reading and current paths; -- writable paths; -- implementation procedure; -- code structure or interface outline where useful; -- acceptance criteria; -- necessary tests and explicitly unnecessary tests; -- stop/escalation conditions; -- completion-report requirements. - -## Reference requirements - -References should contain current revisions, verified source excerpts, authority/ownership boundaries, code shapes, compatibility decisions, and test policy. Smaller models must not be asked to infer architecture that the pack author could have specified. - -## Portable versus installed operation - -Packs may vendor compatibility scripts so the archive is self-contained. The global `agent-workflow` CLI is authoritative when installed. Vendored scripts should be generated or copied from a named workflow release, not edited independently. - -## Integrity - -`MANIFEST.sha256` covers every regular file except itself. The outer archive receives `.sha256`. Archive creation sorts paths, fixes timestamps, and normalizes ownership to reduce nondeterminism. diff --git a/docs/PROVIDER_EVIDENCE_RESEARCH.md b/docs/PROVIDER_EVIDENCE_RESEARCH.md deleted file mode 100644 index 5e5faff..0000000 --- a/docs/PROVIDER_EVIDENCE_RESEARCH.md +++ /dev/null @@ -1,125 +0,0 @@ -# Provider evidence and usage normalization research - -**Status:** implemented in release 0.2.0; hardened in 0.2.1 -**Research date:** 2026-07-24 -**Scope:** Codex/OpenAI-style JSONL, Claude Code stream JSON, immutable run evidence, and comparison-safe token/cost accounting. - -## Decision summary - -`agent-workflow` treats the bounded raw executor event stream as evidence and the normalized usage object as a derived projection. Each classified usage update carries an explicit mode: `delta`, `cumulative`, or `terminal`. A terminal update is authoritative for the run and is never added to earlier deltas. Mixed nonterminal modes invalidate usage. Replayed records with an explicit provider event identity are idempotent. Identical unidentified terminal/cumulative snapshots may be ignored safely, but an identical unidentified `delta` is ambiguous and makes usage incomplete rather than silently undercounting it. Missing or unproven values remain `null`. - -The implementation is in `src/agent_workflow/provider_evidence.py`; contracts are `schemas/provider-evidence.schema.json` and `schemas/trial-evidence.schema.json`; comparison semantics are in `src/agent_workflow/eval/{trials,compare}.py`. - -## Primary sources reviewed - -| Provider surface | Primary source | Verified use | -|---|---|---| -| OpenAI API pricing | https://developers.openai.com/api/docs/pricing | Pricing is model- and date-dependent; runtime estimates require a pinned catalog identity rather than an embedded current price. | -| OpenAI reasoning usage | https://developers.openai.com/api/docs/guides/reasoning | Reasoning-token details may be reported separately from ordinary output tokens. | -| OpenAI prompt caching | https://developers.openai.com/api/docs/guides/prompt-caching | Cached input is a subset/details field and must not be added to input token totals. | -| Codex CLI event examples | https://github.com/openai/codex/issues | Current examples show `turn.completed` usage with input, cached-input, output, and reasoning fields; issue history also demonstrates that CLI fields have changed across releases. | -| Anthropic Messages usage | https://docs.anthropic.com/en/api/messages | Message usage exposes input, cache-creation, cache-read, and output token fields. | -| Anthropic streaming events | https://docs.anthropic.com/en/api/messages-streaming | Streaming has message-start/content-delta/message-delta/message-stop boundaries; intermediate usage is not assumed to be a run total. | -| Anthropic prompt caching | https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching | Cache-read and cache-creation tokens have distinct meanings and billing treatment. | -| Anthropic pricing | https://docs.anthropic.com/en/docs/about-claude/pricing | Provider prices vary by model and cache category; estimates require a pinned price catalog. | -| Claude Code output formats | https://docs.anthropic.com/en/docs/claude-code/cli-reference | `stream-json` is a supported machine-readable CLI surface. | -| Claude Agent SDK result messages | https://docs.anthropic.com/en/api/agent-sdk/python | Final result messages can carry aggregate usage and provider-reported total cost. | - -URLs are recorded because these facts are executor-version-sensitive. The raw event stream and executor version in run provenance remain the evidence needed to reinterpret historical runs. - -## Event envelope - -Every classified update is stored as: - -```json -{ - "sequence": 12, - "event_sha256": "…", - "event_type": "turn.completed", - "mode": "terminal", - "usage": { - "input_tokens": 1000, - "cached_input_tokens": 600, - "cache_write_input_tokens": null, - "output_tokens": 300, - "reasoning_output_tokens": 90, - "provider_total_tokens": 1300, - "provider_billed_cost": null, - "local_estimated_cost": null, - "currency": null, - "price_catalog_id": null - } -} -``` - -### Mode semantics - -- `delta`: values apply only to that event and may be summed with other delta events from the same run. -- `cumulative`: the last valid cumulative update supersedes prior cumulative updates only when numeric counters are nondecreasing and cost metadata is consistent. -- `terminal`: one terminal usage vector is authoritative. Replayed equivalent terminal records are harmless; conflicting terminal vectors invalidate usage. Earlier delta/cumulative records remain provenance but do not fill missing terminal fields. -- mixed `delta` and `cumulative` without a terminal record: invalid, because summing or selecting would require guessing. - -## Field mapping - -| Normalized field | Known variants | -|---|---| -| `input_tokens` | `input_tokens`, `prompt_tokens` | -| `cached_input_tokens` | `cached_input_tokens`, `cache_read_input_tokens`, `cached_tokens`, `input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens` | -| `cache_write_input_tokens` | `cache_write_input_tokens`, `cache_creation_input_tokens` | -| `output_tokens` | `output_tokens`, `completion_tokens` | -| `reasoning_output_tokens` | `reasoning_output_tokens`, `reasoning_tokens`, `output_tokens_details.reasoning_tokens` | -| `provider_total_tokens` | `total_tokens`, `provider_total_tokens` | -| `provider_billed_cost` | explicit provider field such as Claude Code `total_cost_usd`; legacy `cost` only when `cost_source=provider` | -| `local_estimated_cost` | explicit estimate field; legacy `cost` only when `cost_source=local_estimate` | - -Cached input is not a new token category to add to input. It is a billed/details subset of input. Reasoning output is likewise retained as a detail and is not added again when the provider already includes it in output or total tokens. - -## Double-counting hazards and controls - -1. **Terminal plus deltas:** terminal wins; deltas are not summed into it. -2. **Cached input:** cached tokens are never added to `input_tokens`. -3. **Reasoning output:** reasoning details are never added to provider output or total fields. -4. **Duplicate records:** explicit provider event IDs establish replay identity. Without an ID, equivalent terminal/cumulative snapshots may be deduplicated, while duplicate delta lines are flagged as ambiguous and make the run comparison-ineligible. -5. **Retries:** each run has its own evidence and `retry_of_run_id`; cohort aggregation must decide whether retries are separate trials or exclusions rather than merging them silently. -6. **Provider versus estimated cost:** the two fields are never collapsed into one number. -7. **Currency/catalog mismatch:** provider costs are comparable only within one currency; local estimates are comparable only when currency and `price_catalog_id` both match. -8. **Malformed/truncated streams:** usage is incomplete and the trial extractor rejects the run. - -## Bounded capture and immutability - -- Raw executor events must be regular non-symlink files and remain unchanged during capture. Parsing is capped at 16 MiB per run while the complete file is hashed from the same descriptor. -- The evidence records raw byte count, SHA-256, capture limit, completion flag, malformed count, classified records, and incomplete reasons. -- `provider-evidence.json` is sealed into `final-receipt.json`. -- Trial extraction verifies the complete final seal, rejects incomplete provider evidence, and validates `score-set.json` against content-addressed scorer receipts tied to that final receipt. -- The full raw file is hashed with streaming SHA-256; parsing reads at most the declared cap plus one byte. - -## Cost and currency rules - -- `provider_billed_cost` remains `null` unless the provider/executor explicitly reports a billed amount. -- A provider-billed cost requires a currency. -- `local_estimated_cost` remains `null` unless a pinned catalog calculation has been performed. -- A local estimate requires both currency and `price_catalog_id`. -- No current prices are embedded in source. This prevents historical evidence from changing when public pricing changes. -- Cross-cohort cost comparison is excluded when currency or catalog identity differs. - -## Retry, re-steer, and error accounting - -Trial evidence carries: - -- `retry_of_run_id` and runtime `retry_count`; -- structured error records from execution metrics; -- steering count and acknowledged-steering count; -- source artifact digests, including prompt, command collection, provider evidence, and raw events. - -These fields are evidence, not instructions to combine trials. A benchmark manifest must declare inclusion/exclusion semantics for interrupted, retried, or human-assisted runs. - -## Known limits - -- Codex and Claude CLI event JSON are operational interfaces rather than repository-pinned JSON Schemas. Executor version and raw evidence are therefore mandatory provenance. -- Claude assistant-message usage is treated as a per-message delta inside the enclosing CLI run. This is an implementation inference documented in code and must be revalidated when the CLI format changes. -- No paid live baseline/candidate cohort was run in this completion pass. That remains `BKL-004` and requires an explicit benchmark policy decision. -- No local price catalog is shipped; local estimated cost remains `null` unless a future catalog implementation supplies a stable identity. - -## Acceptance evidence - -Focused tests cover terminal authority/conflict/emptiness, delta summation and duplicate ambiguity, cumulative monotonicity, mixed-mode invalidation, cached/reasoning mapping, non-finite values, raw-file type safety, malformed input, size overflow, provider/local cost metadata, forged score-set rejection, incomplete-trial rejection, and comparison exclusions. See `tests/test_provider_evidence.py`, `tests/test_eval_trials.py`, and `tests/test_eval_compare.py`. diff --git a/docs/PUBLIC_RELEASE_READINESS.md b/docs/PUBLIC_RELEASE_READINESS.md new file mode 100644 index 0000000..6294dbe --- /dev/null +++ b/docs/PUBLIC_RELEASE_READINESS.md @@ -0,0 +1,46 @@ +# Public release readiness + +The repository is moving toward a public release, but it is not ready to publish as a supported open-source project yet. This document distinguishes product readiness from repository polish so the project does not accumulate release-shaped artifacts without resolving actual blockers. + +## Current strengths + +- installed-wheel acceptance tests exercise the primary CLI and workflow journeys; +- security, replay, and provider-accounting invariants are compact and explicit; +- deterministic archives, packaged schemas, release manifests, and install/uninstall paths exist; +- architecture, operations, security, testing, prompt-pack, evaluation, MCP, and command documentation are consolidated; +- CI configuration exists for supported Python versions; +- completed prompt packs, ticket ledgers, and one-off audit reports are removed from the public source surface. + +## Blocking decisions + +| Priority | Blocker | Exit evidence | +|---|---|---| +| P0 | Select and add an open-source license | Maintainer-approved `LICENSE` and matching package metadata | +| P0 | Establish a vulnerability-reporting channel | Real monitored contact or private reporting mechanism in `SECURITY.md` | +| P0 | Decide the first supported host/executor matrix | Recorded Linux/Python/tmux/Codex/Claude versions and successful live compatibility evidence | +| P0 | Define release ownership and signing | Named maintainer process, protected tags, checksums, and release notes | +| P1 | Publish package/repository metadata | Final project URLs, license classifier, support policy, and distribution target | +| P1 | Run a clean-machine install/uninstall trial | Evidence from a representative host outside the development checkout | +| P1 | Run a real workflow/provider cohort | Sealed, reviewable evidence with no unsupported performance claims | + +Until the P0 blockers are closed, use source archives with trusted collaborators rather than describing the project as a supported public release. + +## Non-blocking future work + +The state-mutating MCP phase, remote transport, multi-host orchestration, and host routing enforcement are not prerequisites for the first public CLI release. They must not delay a focused release or be pulled in merely to appear complete. + +## Release gate + +A release candidate should pass: + +```bash +pytest +./scripts/release-check.sh +python -m build +``` + +In addition, run the opt-in live compatibility lane on each declared supported host/executor combination and review the resulting sealed evidence. CI success alone is not sufficient for provider compatibility. + +## Documentation policy + +Public documentation describes current behavior, supported boundaries, and active plans. Historical implementation plans, completion receipts, session checkpoints, changed-file ledgers, and cleanup reports belong in Git history or release attachments—not as permanent top-level documentation. diff --git a/docs/SECURITY_TRUST_BOUNDARIES.md b/docs/SECURITY_TRUST_BOUNDARIES.md deleted file mode 100644 index 051a845..0000000 --- a/docs/SECURITY_TRUST_BOUNDARIES.md +++ /dev/null @@ -1,34 +0,0 @@ -# Security trust boundaries - -## Boundary map - -1. **Operator/config input:** trusted intent but validated types, IDs, policy, and paths. -2. **Prompt-pack content:** project-controlled and potentially adversarial; constrained by pack roots, manifests, schemas, checksums, and writable scopes. -3. **Target repository/worktree:** untrusted code executed only through declared commands/executors; isolated per ticket. -4. **Executor process:** external binary with versioned provenance; stdout/stderr are untrusted bounded evidence. -5. **Durable state:** local filesystem authority; atomic JSON, fsynced JSONL, regular-file/symlink checks, and read-only receipts detect substitution. -6. **Reviewer:** separate lifecycle authority; acceptance requires canonical evidence and exact revision. -7. **MCP client:** local stdio principal plus untrusted tool content; current surface is bounded/read-only. Network identity is not yet authorized. - -## Authority matrix - -| Question | Authoritative evidence | Not authoritative | -|---|---|---| -| Did a run execute/finish? | lifecycle events, final status, final receipt | tmux pane text alone | -| Did a child apply a steer? | correlated acknowledgement/application record | steer write, wait-for signal, prose claim | -| Is a run reviewable/approved? | sealed `final-status.json`, canonical final receipt, and canonical lifecycle receipt chain | mutable `status.json` state, tier, executor, digest, or receipt pointer | -| What workflow state exists? | normalized snapshot + contiguous workflow event journal | workflow status projection alone | -| What result value may a child consume? | sealed ancestor `result.json` + binding snapshot | arbitrary files or live source reads | -| What tokens/cost count? | bounded raw provider events + explicit normalization mode | logs, model estimates without catalog | -| Is a release complete? | tests, release audit, manifest, checksums | version string or hand-written report alone | - -## Required controls - -- reject traversal, symlink escapes, unsafe IDs, unknown schema fields, and over-limit data; -- reserve append-only records before projections or external-effect claims; -- preserve prior runs and retry lineage; -- keep provider billed versus estimated cost distinct; -- require exact workflow snapshot matching after start; -- derive lifecycle-review authority from sealed terminal evidence and verify canonical final/lifecycle/workflow receipts as regular non-symlink read-only files; -- never infer approval, delivery, cost, or completion from terminal output; -- avoid vendored third-party source unless it is an intentional maintained dependency boundary. diff --git a/docs/STALL_RECOVERY.md b/docs/STALL_RECOVERY.md deleted file mode 100644 index 74782dd..0000000 --- a/docs/STALL_RECOVERY.md +++ /dev/null @@ -1,29 +0,0 @@ -# Stall Recovery - -A session is potentially stalled when tmux is alive and its log has not grown for the configured interval. This signal is deliberately conservative. - -## Inspect - -```bash -agent-workflow status SESSION --capture 100 -agent-workflow attach SESSION -``` - -Classify: - -- waiting for operator input; -- package or network operation; -- test/build deadlock; -- repeated agent reasoning loop; -- legitimate long-running command with quiet output; -- child process detached from the pane. - -## Recover - -1. Interrupt with Ctrl-C. -2. Record the reason in completion notes. -3. Correct the environment or prompt. -4. Restart into `SESSION-retryN`. -5. Do not erase the original log. - -Automatic timer-based killing is intentionally excluded from v0.1. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..61f6714 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,98 @@ +# Testing strategy + +The test suite is organized around behavior that an operator can observe. Test count is not a quality target; each retained test must protect a user journey, a security/state invariant, a release property, or an approved future capability. + +## Test layers + +### Acceptance journeys + +`tests/acceptance/` builds a wheel from a clean source copy, installs it into an isolated virtual environment, and invokes the installed `agent-workflow` or `agent-workflow-mcp` executable as an external process. + +The acceptance layer covers: + +- installed CLI discovery, configuration, doctor, and actionable failures; +- prompt-pack scaffold, validation, and deterministic archive output; +- real Git worktree creation, listing, and removal; +- external executor launch, completion, failure, restart, review, acceptance, and interactive-agent reuse; +- durable steer/watch/ack replay across process boundaries; +- structured provider-event collection into sealed normalized evidence; +- workflow validation, scheduling, restart/resume, approval, idempotency, sealing, and verification; +- authorized template expansion through the installed CLI; +- evaluation-plan validation and matched baseline/candidate comparison. + +The deterministic fake executor and tmux shim are external executables, not mocked Python functions. They make process boundaries reproducible without requiring paid provider calls in the default suite. + +### Invariant matrices + +`tests/invariants/` is deliberately small. It directly exercises logic that needs exhaustive or adversarial coverage and would be expensive or nondeterministic to reproduce solely through end-to-end journeys: + +- seal substitution, symlink, traversal, and read-only boundaries; +- append-only message ordering and fail-closed replay; +- scheduler dependency and parallelism rules; +- deterministic advisory routing that cannot override enforced policy; +- provider delta/cumulative/terminal accounting and duplicate identity rules; +- evaluation cohort identity and low-sample claims; +- the bounded JSON Pointer subset used for result binding. + +Prefer one parameterized matrix to many nearly identical tests. + +### Release checks + +`tests/release/` validates distribution properties: repository release assets, JSON Schemas, shell syntax, and agreement between documented primary commands and installed help. Static documentation and metadata checks belong here, not in behavioral unit tests. + +### Future acceptance specifications + +`tests/future/` contains approved backlog behavior expressed as black-box journeys. These tests are `xfail(strict=True)`: they execute and expose the current gap, while an unexpected pass fails the suite until the test is reviewed and promoted into `tests/acceptance/`. + +A future test must name an approved backlog item and specify an operator-visible result. Parser shape, private helper calls, or speculative interfaces are not acceptable future tests. + +### Live compatibility + +`tests/live/` is opt-in because it requires host resources or paid services. It is intended for real tmux, Codex, Claude, and MCP compatibility checks before release. Set the documented environment switches and run: + +```bash +pytest -m live +``` + +## Rules for new tests + +A proposed test should answer at least one of these questions: + +1. What complete user action would break without this test? +2. What security, replay, accounting, or durability invariant requires exhaustive isolated coverage? +3. What approved backlog capability does this executable future specification define? +4. What distributable release property does this check protect? + +Do not add a test merely because a function, branch, parser field, dictionary shape, or prose fragment exists. Avoid private imports and mocks unless the test is an invariant that cannot be exercised deterministically through a public boundary. + +When a defect is discovered, first extend the nearest end-to-end journey. Add a narrow invariant only when the defect belongs to a general security/state matrix. + +## Commands + +```bash +# Default release-development gate +pytest + +# Behavioral acceptance only +pytest tests/acceptance + +# Security/state/accounting matrices +pytest tests/invariants + +# Static distribution checks +pytest tests/release + +# Approved future specifications +pytest tests/future + +# Real host/provider compatibility +AGENT_WORKFLOW_LIVE_TMUX=1 pytest -m live +AGENT_WORKFLOW_LIVE_EXECUTOR=codex pytest -m live +AGENT_WORKFLOW_LIVE_EXECUTOR=claude pytest -m live +``` + +`./scripts/release-check.sh` runs the default suite plus compile, shell, schema, and release-asset checks. + +## Current shape + +The testing rewrite replaced 239 implementation-heavy tests across 46 files with a compact suite organized by product journeys and invariant matrices. Deleted tests are preserved in Git history and should not be restored merely to recover coverage numbers. Restore a behavior only by expressing it through the test layers above. diff --git a/docs/TEST_POLICY.md b/docs/TEST_POLICY.md deleted file mode 100644 index ea280c2..0000000 --- a/docs/TEST_POLICY.md +++ /dev/null @@ -1,22 +0,0 @@ -# Test Policy - -Tests are selected by contract, not by coverage percentage. - -## Appropriate - -- pure unit test for a parser, validator, normalizer, migration helper, or stable ID function; -- contract test for JSON/status/schema/CLI exit behavior; -- one integration test across a critical seam; -- opt-in live test under an explicit marker and isolated environment. - -## Usually inappropriate - -- tests that assert user-created database files exist; -- tests tied to absolute home paths; -- one help-text test per subcommand when a parser smoke test suffices; -- snapshots of broad output when semantic fields can be asserted; -- duplicated package/install tests before packaging is a supported product surface; -- tests added only to increase line coverage; -- live internet tests as normal phase gates. - -Each proposed test must answer: “What specific regression or contract does this protect?” If the answer duplicates an existing test, extend or reuse that test. diff --git a/docs/WORKFLOW_FOUNDATIONS_PLAN.md b/docs/WORKFLOW_FOUNDATIONS_PLAN.md deleted file mode 100644 index 3f027a8..0000000 --- a/docs/WORKFLOW_FOUNDATIONS_PLAN.md +++ /dev/null @@ -1,84 +0,0 @@ -# Workflow foundations: implemented architecture and remaining evolution - -**Status:** WF-001, WF-002, WF-00, WF-01, WF-02, WF-10, WF-11, WF-12, WF-20, WF-21, and WF-22 completed in release 0.2.0; authority and replay hardening completed in 0.2.1. - -This document records the implemented boundary. `BACKLOG.md` remains the authority for unfinished work. - -## Implemented foundation - -### Dependency graph validation - -Prompt-pack dependencies are validated across phases as a directed acyclic graph. Unknown, malformed, self, duplicate, and cyclic references fail validation before execution. - -### Structured task result contracts - -Tickets may declare a pack-local JSON Schema. The runner performs bounded regular-file reads of `result.json`, validates it, writes a collection receipt, and seals accepted result evidence. Result contracts do not create a scheduler or arbitrary file channel. - -### Workflow snapshot and replay - -A canonical snapshot defines workflow ID, pack provenance, nodes, dependencies, task/approval kind, launch/routing metadata, retry policy, and optional input bindings. Start stores that snapshot as a regular read-only file. Duplicate node, dependency, and task-session identifiers fail before execution. Append-only events record node transitions and bindings; existing journals are schema/sequence validated before append and read under a shared lock. Status/run files are deterministic projections rebuilt and refreshed from the snapshot and contiguous journal. - -### Restart-safe scheduler - -`SchedulerService` schedules only eligible nodes and subtracts already-running nodes from the parallelism budget. A callback return value cannot establish a child run: a matching regular provenance contract or valid sealed final receipt must exist before the node enters `running`. Restart/resume replays durable records, reconciles sealed terminal child outcomes, marks missing child footprints recoverable, propagates dependency failure, and reopens dependency-failed descendants when a prerequisite retry changes the dependency state. Attempts and retry lineage remain append-only. - -### Receipt-backed approvals - -Approval nodes reference a subject task. They complete only when the subject has a valid canonical accepted lifecycle receipt chain for the sealed child run and exact completion revision. Lifecycle receipt creation and workflow approval reconstruction derive authority from sealed terminal artifacts and ignore mutable `status.json` state, identity, tier, digest, and receipt pointers. Rejection or tampering fails the gate and propagates dependency failure. - -### Bounded result binding - -A task may bind named inputs from completed ancestor task results with strict RFC 6901 JSON Pointers. The source result and collection must be sealed by a canonical final receipt. Required/missing behavior, per-value size, total size, ancestry, and unsafe file types are enforced. Values are copied into a read-only binding snapshot and sealed child provenance; children never read arbitrary predecessor files dynamically. - -### Aggregate workflow receipts - -A terminal workflow can be sealed into a read-only receipt committing to the normalized/file snapshot digests, event journal digest/count, exact node set and states, terminal reasons, attempts/retry lineage, binding history and input digests, child final receipt/completion digests, approval digests, and workflow disposition. Verification rebuilds the value from current durable evidence and requires exact equality. - -### Authorized templates - -Only these deterministic expansion templates exist: - -1. `pipeline` -2. `parallel-review-fan-in` -3. `implementation-independent-review` - -Expansion produces the same canonical snapshot schema as hand-authored workflows. - -### Explainable routing advice - -Routing advice deterministically recommends exploratory, review, or implementation class from bounded node metadata and records stable explanation codes. It is advisory. Existing configured class/executor/model allowlists, interactivity, permission arguments, and no-go policy remain the enforcement authority. Recommendation/enforced selection disagreements are explicit. - -## Non-targets retained - -- no alternate executor or MCP-specific scheduler; -- no online learning, vector memory, or autonomous model selection; -- no arbitrary expression language or file reads in result binding; -- no approval inferred from logs, status pointers, or process success; -- no automatic merge or destructive workflow cleanup; -- no multi-host broker until a measured need and explicit decision. - -## CLI - -```text -agent-workflow workflow validate SNAPSHOT -agent-workflow workflow template TEMPLATE SPEC --output SNAPSHOT -agent-workflow workflow start RUN_DIR SNAPSHOT -agent-workflow workflow status RUN_DIR SNAPSHOT -agent-workflow workflow resume RUN_DIR SNAPSHOT -agent-workflow workflow seal RUN_DIR SNAPSHOT -agent-workflow workflow verify RUN_DIR SNAPSHOT -``` - -The supplied snapshot must match the stored started snapshot for every operation after start. - -## Evidence and tests - -Schemas live in `schemas/workflow-*.schema.json`, `schemas/routing-advice.schema.json`, and `schemas/workflow-input-bindings.schema.json`. Focused tests live in `tests/test_workflow.py`, `tests/test_approval.py`, `tests/test_workflow_receipt.py`, `tests/test_workflow_templates.py`, and `tests/test_routing.py`. - -See [Repository Chart Pack](diagrams/REPOSITORY_CHART_PACK.md) for state, authority, binding, approval, and receipt diagrams. - -## Remaining evolution - -- `MCP-003`: expose safe workflow/session/message mutations through shared services with durable idempotency. -- `BKL-004`: run controlled real-executor benchmark cohorts. -- `WF-006`: consider evidence-derived routing recommendations only after comparable cohort evidence exists. diff --git a/docs/diagrams/REPOSITORY_CHART_PACK.md b/docs/diagrams/REPOSITORY_CHART_PACK.md index 16af0b5..9063645 100644 --- a/docs/diagrams/REPOSITORY_CHART_PACK.md +++ b/docs/diagrams/REPOSITORY_CHART_PACK.md @@ -1,6 +1,6 @@ # agent-workflow repository chart pack -**Release:** 0.2.1 +**Release:** 0.2.2 **Purpose:** current-state architecture, data/evidence model, execution flows, security boundaries, and planned MCP evolution. Mermaid sources for the highest-value diagrams are also stored as individual `.mmd` files in this directory. @@ -396,9 +396,11 @@ flowchart LR flowchart LR Source[Git source checkout] --> Audit[audit-release-assets.py] Audit --> Manifest[MANIFEST.sha256] - Source --> Tests[pytest + compileall + shell syntax] + Source --> Tests[installed-wheel acceptance + invariant matrices] + Source --> Static[release/schema/shell/compile checks] Manifest --> ReleaseCheck[scripts/release-check.sh] Tests --> ReleaseCheck + Static --> ReleaseCheck ReleaseCheck --> Archive[deterministic tar.zst + SHA-256] Source --> Install[install.sh editable install] Install --> Launcher[~/.local/bin/agent-workflow] @@ -494,6 +496,40 @@ flowchart TB Seals --> Review[Independent review/acceptance] ``` +## 23. Acceptance-first test architecture + +```mermaid +flowchart TB + Source[Clean source copy] --> Wheel[Build wheel] + Wheel --> Venv[Install isolated virtualenv] + Venv --> CLI[Invoke installed executables] + CLI --> Journeys[Acceptance journeys] + Journeys --> Git[Real Git/worktrees] + Journeys --> Proc[External tmux/executor shims] + Journeys --> State[Durable state and receipts] + Invariants[Compact invariant matrices] --> Security[Path/seal/symlink rules] + Invariants --> Replay[Message/workflow replay] + Invariants --> Accounting[Provider/cohort accounting] + Future[Strict xfail future journeys] --> Backlog[Approved backlog outcomes] + Live[Opt-in live compatibility] --> RealHost[Real tmux/providers/MCP host] + Release[Release checks] --> Distribution[schemas/help/shell/manifest] +``` + +## 24. Public release path + +```mermaid +flowchart LR + Core[Core CLI/workflow/evidence] --> Acceptance[Acceptance-first suite] + Acceptance --> CI[Supported Python CI] + CI --> Host[Clean-host live compatibility] + Host --> Governance[License + security contact + ownership] + Governance --> Metadata[Package/repository metadata] + Metadata --> RC[Signed reproducible release candidate] + RC --> Public[Supported public release] + MCPMutation[MCP mutation] -. not a prerequisite .-> Public + MultiHost[Multi-host orchestration] -. not a prerequisite .-> Public +``` + ## Diagram maintenance rule Update this chart pack whenever a release changes a durable authority, package boundary, workflow state, public CLI family, MCP capability, evidence schema, or release/install flow. Historical plans may retain old diagrams only when clearly labeled as historical. diff --git a/docs/diagrams/public-release-path.mmd b/docs/diagrams/public-release-path.mmd new file mode 100644 index 0000000..60ee896 --- /dev/null +++ b/docs/diagrams/public-release-path.mmd @@ -0,0 +1,10 @@ +flowchart LR + Core[Core CLI/workflow/evidence] --> Acceptance[Acceptance-first suite] + Acceptance --> CI[Supported Python CI] + CI --> Host[Clean-host live compatibility] + Host --> Governance[License + security contact + ownership] + Governance --> Metadata[Package/repository metadata] + Metadata --> RC[Signed reproducible release candidate] + RC --> Public[Supported public release] + MCPMutation[MCP mutation] -. not prerequisite .-> Public + MultiHost[Multi-host orchestration] -. not prerequisite .-> Public diff --git a/docs/diagrams/testing-strategy.mmd b/docs/diagrams/testing-strategy.mmd new file mode 100644 index 0000000..433b805 --- /dev/null +++ b/docs/diagrams/testing-strategy.mmd @@ -0,0 +1,14 @@ +flowchart TB + Source[Clean source copy] --> Wheel[Build wheel] + Wheel --> Venv[Install isolated virtualenv] + Venv --> CLI[Invoke installed executables] + CLI --> Acceptance[Operator-visible journeys] + Acceptance --> Git[Real Git boundary] + Acceptance --> Process[External process boundary] + Acceptance --> Evidence[Durable state and receipts] + Invariants[Compact matrices] --> Security[Seal/path/symlink] + Invariants --> Replay[Messages/workflows] + Invariants --> Accounting[Provider/cohorts] + Future[Strict xfail future journeys] --> Backlog[Approved outcomes] + Live[Opt-in live lane] --> Hosts[Real tmux/providers/MCP] + Release[Distribution checks] --> Assets[Schemas/help/shell/manifest] diff --git a/docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md b/docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md deleted file mode 100644 index 790489e..0000000 --- a/docs/execution-evidence/BKL-003-RESEARCH-TICKET-COMPLETION.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-3 -ticket: BKL-003-RESEARCH -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# BKL-003-RESEARCH Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Completed a primary-source research memo for provider-neutral usage evidence and current supported executor event surfaces. Facts, implementation inferences, unknown/null behavior, cached-token variants, currency/catalog rules, duplicate/retry semantics, and real-executor cohort exclusions are separated explicitly. - -## Files changed - -- `docs/PROVIDER_EVIDENCE_RESEARCH.md` -- supporting architecture/security/MCP research documents -- implementation checklist embodied in schemas/tests - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| Primary official sources recorded with access dates | pass | OpenAI, Anthropic, and MCP official documentation references | -| Delta/cumulative/terminal envelope specified | pass | memo and schema design | -| Cached/reasoning/cost variants and double-counting risks mapped | pass | provider mapping table and invalid-mode rules | -| Unknowns/open cohort questions are explicit | pass | BKL-004 remains open; no paid-provider result claimed | -| No runtime change in research ticket | pass | research memo is separable from subsequent implementation commit scope | - -## Tests and validation - -Read-only source/reference validation and prompt-pack validation passed. No fresh external research-review executor was available; this limitation is recorded rather than replaced with a fabricated receipt. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/BKL-003-TICKET-COMPLETION.md b/docs/execution-evidence/BKL-003-TICKET-COMPLETION.md deleted file mode 100644 index 93ff6f5..0000000 --- a/docs/execution-evidence/BKL-003-TICKET-COMPLETION.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-3 -ticket: BKL-003 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# BKL-003 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Implemented bounded raw provider stream preservation, event-level digests/sequences, explicit usage modes, provider adapters, sealed provider evidence, retry/error/control accounting, immutable trial evidence, and valid cohort comparison semantics. - -## Files changed - -- `src/agent_workflow/provider_evidence.py`, runner/provenance/metrics/receipt integration -- evaluation trial collection/comparison -- provider, trial, provenance, and metrics schemas -- provider/eval/runner/session tests - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| Raw bounded stream evidence is preserved before normalization | pass | 16 MiB max+1 capture, digest, byte count, completeness flag | -| Usage modes do not double-count | pass | terminal authority; mixed nonterminal modes invalidate fields | -| Duplicate/replayed events are idempotent | pass | event-digest deduplication tests | -| Unknown tokens/cost/currency remain null | pass | null tests and schema contracts | -| Provider billed and local estimate are distinct | pass | separate fields; currency/catalog comparison exclusions | -| Retry/re-steer/error and provenance digests are retained | pass | sealed provenance lineage, controls, metrics, and trial source hashes | -| Incomplete/inconsistent trials are rejected | pass | evaluation collection/comparison tests | - -## Tests and validation - -`PYTHONPATH=src python3 -m pytest -q tests/test_provider_evidence.py tests/test_metrics.py tests/test_eval_trials.py tests/test_eval_compare.py tests/test_runner_generation.py` — focused provider/evaluation/runner coverage passed. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/FINAL_CRITICAL_REVIEW.md b/docs/execution-evidence/FINAL_CRITICAL_REVIEW.md deleted file mode 100644 index 5290f40..0000000 --- a/docs/execution-evidence/FINAL_CRITICAL_REVIEW.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -schema: agent-workflow/phase-gate/v1 -pack_id: chatgpt-workflow-completion-next -phase: release-0.2.1-critical-repair -review_session: local-critical-repair-20260724 -decision: accepted_with_follow_up ---- - -# Release 0.2.1 Critical Review - -## Supersession notice - -This report supersedes the 0.2.0 final critical review. The earlier conclusion that no known correctness or security defect remained was too optimistic. A fresh review reproduced and corrected material scheduler, replay, evidence-authority, and concurrent-sealing defects. The 0.2.0 gate remains in Git history and `WORKFLOW_BENCHMARK_PHASE_GATE.md` only as historical evidence. - -## Review method - -The delivered source archive was extracted independently at commit `4ece38c` and treated as an untrusted release candidate. The review traced workflow state from canonical files through replay, scheduling, projection, retry, sealing, trial extraction, and provider normalization; wrote regression tests before repairs; ran focused negative/tamper suites; audited documentation and release metadata; and repeated the cumulative suite outside the foreground tool timeout. - -## Defects found and corrected - -1. **Active-capacity oversubscription.** Existing `running` nodes did not consume `max_parallelism`. Planning now subtracts active nodes before selecting work. -2. **Circular child authority.** A historical workflow event or a callback result containing only `run_id` could prove a child existed. Running state now requires a matching regular provenance contract or verified final receipt in the canonical child run directory. -3. **Missing terminal reconciliation.** Restarted workflows did not automatically consume sealed child outcomes. Running children are now reconciled from final status, completion collection, and the verified final receipt. -4. **Retry replay contradiction.** The CLI allowed retry from `recoverable`, but event replay rejected the resulting binding. Replay and transition rules now agree. -5. **Permanently failed descendants.** Dependency-propagated failures did not reopen after a prerequisite retry. Those nodes return to `blocked`, then become eligible only after the prerequisite completes. -6. **Projection authority and staleness.** Start/status logic depended on `workflow-run.json`, and projections could be stale immediately after scheduling. The immutable snapshot and event journal establish a started workflow; mutable projections are regenerated after scheduling and on status reads. -7. **Snapshot and journal durability gaps.** Canonical snapshots remained writable, journal appends did not validate prior content under one lock, and directory entries were not always fsynced. Snapshots and workflow receipts are made read-only before their atomic rename; snapshots are descriptor-validated; journals reject symlink/non-regular substitutions, validate contiguous history before append, lock stable reads, and persist directory-entry changes. -8. **Concurrent sealing race.** Receipt construction could reconstruct one journal state and hash another while scheduling appended events. Scheduling, sealing, verification, and projection refresh now share a descriptor-safe workflow lock. -9. **Duplicate graph identifiers.** Duplicate dependency IDs and task session IDs were accepted. Normalization and JSON Schema now reject both. -10. **Mutable score verdict authority.** Trial extraction trusted `score-set.json` without validating the referenced scorer receipts. Extraction now validates content-addressed receipts, expected scorers, final-receipt binding, and the derived verdict. -11. **Provider usage undercount and false completeness.** Empty/conflicting terminal totals, non-finite values, regressing cumulative totals, incomplete currency/catalog metadata, and equal unidentified delta events could be accepted or silently deduplicated. These cases now fail closed; explicit event identities remain idempotent and distinct identified deltas count independently. -12. **Raw stream substitution.** Provider capture could follow symlinks or observe a file changing between parse and hash. Capture now requires one regular non-symlink descriptor, hashes the complete stream, bounds parsing, and rejects in-flight mutation. -13. **Receipt-root and final-seal races.** Lifecycle code resolved `receipts/` before testing for a symlink, and final-run seal creation/verification lacked a shared serialization boundary. Receipt roots and seal locks now reject symlinks/non-regular files, directory entries are fsynced, and final receipts/artifacts are read and hashed from stable descriptors. -14. **Split receipt verification and digest reads.** Several consumers verified `final-receipt.json` and then hashed its pathname in a second operation; aggregate workflow receipts used the same split read/stat/hash pattern. Verification now returns the digest of the exact final-receipt bytes read under `seal.lock`, and workflow receipts are parsed, mode-checked, and hashed from one non-symlink descriptor under `workflow.lock`. -15. **Mutable content-addressed scorer receipts.** Scorer receipts used content-derived filenames but were writable and could be read through symlinks. They are now atomically installed read-only, validated from regular non-symlink descriptors, and rejected if an existing content-addressed path is writable or disagrees with its object. Lifecycle review hashes the exact score-set bytes it validated. -16. **Post-verification sealed-artifact reopen.** Lifecycle review, approval gates, scheduler reconciliation, result binding, workflow receipt construction, and trial extraction verified a final seal and then reopened sealed JSON by pathname. Those authority paths now read the exact receipt-listed artifact through beneath-root, no-symlink descriptors and recheck size/hash before using it. -17. **Incomplete and unsafe read-only pass.** The optional `assignments/` evidence tree was omitted, and chmod could follow a symlink to an outside target. All sealed required/optional trees are now covered, symlinks are rejected, and chmod is descriptor-based. Seal creation and verification also reject intermediate symlink components even when they point back inside the run. -18. **Writable copied launch inputs.** Parent workflow binding snapshots were read-only, but the child copy and native-job binding artifacts remained writable until terminal sealing. Parent and child workflow inputs, native-job snapshots, and binding receipts are now made read-only before their atomic rename and before executor launch. -19. **Stale release evidence.** The feature ledger, MCP follow-on baseline, man pages, cleanup audit, phase-gate status, diagrams, backlog history, and version markers still represented 0.2.0 as current. Current surfaces now identify 0.2.1 and explicitly preserve 0.2.0 only as historical provenance. - -## Verification evidence - -| Gate | Result | -|---|---| -| Focused authority, evidence, receipt, launch, and immutable-input suites | 101 passed; 13 subtests passed | -| Cumulative suite excluding release-manifest tests | 235 passed; 1 optional integration skipped; 49 subtests passed in 43.74 seconds | -| Full repository suite with regenerated release manifest | 238 passed; 1 optional integration skipped; 49 subtests passed in 45.12 seconds | -| Schema, compilation, shell syntax, prompt-pack, and manifest checks | Passed | -| Source/release archive and wheel checks | Recorded in the final delivery summary after packaging | - -## Boundary and simplification audit - -- Canonical append-only records and sealed receipts remain authoritative; mutable status, run projections, logs, and terminal capture do not. -- No second scheduler, executor, daemon, broker, database, HTTP service, arbitrary shell/path surface, or MCP mutation implementation was added. -- The shared workflow lock is a local coordination primitive, not a new state store. -- Historical prompt packs and ticket reports remain for reproducibility but are labeled as history rather than active architecture or task tracking. -- No additional vendored dependency or removable runtime subsystem was found after the 0.2.0 SDK cleanup. - -## Remaining follow-up - -- `BKL-004` still requires a controlled real-provider cohort; no model or routing winner is claimed from fixtures. -- MCP mutation work remains `MCP-003`, ready but not implemented. -- No independent external Codex/Claude reviewer executable was available. This review therefore records local executable evidence and does not fabricate an external receipt. - -## Decision - -Accept release 0.2.1 with the external-review and real-provider-cohort follow-ups above. Unlike the superseded 0.2.0 report, this decision explicitly records the defects discovered after delivery and the regression boundaries added for each repair. diff --git a/docs/execution-evidence/P0-00-TICKET_COMPLETION.md b/docs/execution-evidence/P0-00-TICKET_COMPLETION.md deleted file mode 100644 index 0aae819..0000000 --- a/docs/execution-evidence/P0-00-TICKET_COMPLETION.md +++ /dev/null @@ -1,12 +0,0 @@ -# Ticket completion: P0-00 - -- Ticket: P0-00 -- Mode: read-only baseline/preflight -- Source: supplied release archive -- Declared release: `0.1.6` -- Git branch/revision/dirty state: unavailable because the supplied source archive contains no `.git` metadata -- Prompt-pack reviewed baseline: supplied release 0.1.6 archive, reviewed 2026-07-23 -- Path mapping: all P0-01 referenced paths exist; `skills/agent-workflow-orchestrator/SKILL.md` was absent as expected -- Material drift: no newer conflicting architecture found; current installer covered `~/.agents/skills` and `~/.claude/skills` only -- Production source changed during preflight: no -- Decision: proceed with P0-01 against the supplied 0.1.6 archive diff --git a/docs/execution-evidence/P0-01-TICKET_COMPLETION.md b/docs/execution-evidence/P0-01-TICKET_COMPLETION.md deleted file mode 100644 index 3995fbe..0000000 --- a/docs/execution-evidence/P0-01-TICKET_COMPLETION.md +++ /dev/null @@ -1,9 +0,0 @@ -# Ticket completion: P0-01 - -- Added `skills/agent-workflow-orchestrator/SKILL.md` with decision, launch, control, recovery, review, tmux, native-subagent, and steering contracts. -- Cross-linked and corrected all three existing workflow skills. -- Installed all four skills into shared, Codex, and Claude discovery roots using owned symlinks. -- Updated uninstall ownership behavior, README, installation docs, P0 design record, and BKL-006 state. -- Added narrow installer and skill-contract tests. -- Focused gate: `PYTHONPATH=src python3 -m unittest tests.test_install_uninstall tests.test_skill_contracts -v` — passed. -- Source scope exception: `uninstall.sh` was changed because installer ownership must be reversible and safely tested; no runtime lifecycle code changed. diff --git a/docs/execution-evidence/P1-00-TICKET_COMPLETION.md b/docs/execution-evidence/P1-00-TICKET_COMPLETION.md deleted file mode 100644 index 0e43024..0000000 --- a/docs/execution-evidence/P1-00-TICKET_COMPLETION.md +++ /dev/null @@ -1,7 +0,0 @@ -# Ticket completion: P1-00 - -- Produced `docs/MCP_SERVER_DECISION.md`. -- Recommendation: optional thin Python MCP server, local stdio first, no runtime implementation in this phase. -- Used current repository architecture plus official MCP specification, documentation, and SDK repositories only. -- Proposed backlog entries are included in the memo but were not applied. -- No MCP dependency, runtime code, schema, skill, or configuration was added. diff --git a/docs/execution-evidence/PHASE_GATE_REPORT.md b/docs/execution-evidence/PHASE_GATE_REPORT.md deleted file mode 100644 index a180148..0000000 --- a/docs/execution-evidence/PHASE_GATE_REPORT.md +++ /dev/null @@ -1,23 +0,0 @@ -# Phase Gate Report - -**Historical evidence notice:** This report records the earlier skills/MCP Phase 0-1 gate and is not the current workflow-completion gate. Current 0.2.1 correction evidence is recorded in `WORKFLOW_BENCHMARK_PHASE_GATE.md` and `FINAL_CRITICAL_REVIEW.md`. - -## Decision - -**Accept Phase 0 with validation caveat; accept Phase 1 research artifact.** - -## Evidence - -- Focused P0 tests passed: installer ownership, three discovery roots, idempotence, unrelated-path refusal, skill metadata, orchestration links, and canonical lifecycle claims. -- Shell syntax checks passed for `install.sh` and `uninstall.sh`. -- Release asset manifest was regenerated and independently validated. -- The original skill/MCP execution did not change runtime lifecycle code. A later cleanup removed an unrelated project-specific adapter without changing the skill lifecycle contract. -- Phase 1 added only `docs/MCP_SERVER_DECISION.md` and evidence reports; no MCP runtime dependency or implementation was added. - -## Validation status - -The release gate was rerun during the subsequent repository-neutrality cleanup. It progressed through the suite with passing results until the execution environment command limit interrupted it during runner tests. The runner group and every remaining test module were then run in bounded groups and passed. No assertion failure was observed. - -## Scope review - -`uninstall.sh` was changed although the ticket's abbreviated writable list named `install.sh`; this was necessary to preserve symmetric owned-link removal for the new skill and Codex root. The change is narrow, tested, and does not alter runtime lifecycle behavior. diff --git a/docs/execution-evidence/WF-10-TICKET-COMPLETION.md b/docs/execution-evidence/WF-10-TICKET-COMPLETION.md deleted file mode 100644 index 042ac23..0000000 --- a/docs/execution-evidence/WF-10-TICKET-COMPLETION.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-1 -ticket: WF-10 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# WF-10 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Implemented receipt-backed approval gates over the canonical lifecycle receipt directory. The original delegated WF-10 commit was not accepted because it followed a mutable receipt path from `status.json`; this completion replaces that authority with contiguous regular-file/read-only chain reconstruction and also hardens lifecycle receipt creation to derive completion, tier, executor identity, and final-receipt digest from sealed terminal evidence. - -## Files changed - -- `src/agent_workflow/approval.py`, `src/agent_workflow/lifecycle.py`, `src/agent_workflow/scheduler.py` -- `tests/test_approval.py`, `tests/test_lifecycle.py` -- workflow/architecture/security documentation - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| Accepted and rejected paths are distinct | pass | scheduler transitions approval nodes from canonical accepted/rejected dispositions | -| Mutable approval state is not authoritative | pass | status-projection tamper test plus direct canonical receipt reconstruction | -| Tampered, unrelated, copied, omitted, duplicated, symlinked, writable, or noncontiguous receipts fail closed | pass | `tests/test_approval.py` | -| Downstream eligibility follows durable evidence | pass | scheduler approval/dependency tests | - -## Tests and validation - -`PYTHONPATH=src python3 -m pytest -q tests/test_lifecycle.py tests/test_approval.py tests/test_workflow_receipt.py` — 17 passed, 4 subtests passed. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-11-TICKET-COMPLETION.md b/docs/execution-evidence/WF-11-TICKET-COMPLETION.md deleted file mode 100644 index 5717c38..0000000 --- a/docs/execution-evidence/WF-11-TICKET-COMPLETION.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-1 -ticket: WF-11 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# WF-11 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Implemented declared JSON Pointer result binding from completed ancestor task results. Values and source digests are copied into immutable per-node/per-attempt snapshots and child provenance before launch; downstream agents do not dynamically read predecessor files. - -## Files changed - -- `src/agent_workflow/bindings.py`, scheduler/provenance integration -- workflow input-binding schemas -- `tests/test_bindings.py` and workflow retry coverage - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| Strict JSON Pointer subset, no expression language | pass | malformed escapes, array tokens, leading-zero indices, and non-pointer forms rejected | -| Valid sealed ancestor results only | pass | collection and final-receipt digests verified | -| Missing required and oversized values fail closed | pass | per-value 1 MiB and aggregate 4 MiB limits tested | -| Retry/replay behavior is deterministic | pass | immutable snapshot replays byte-for-byte with preserved `created_at` | - -## Tests and validation - -`PYTHONPATH=src python3 -m pytest -q tests/test_bindings.py tests/test_workflow.py` — focused binding/retry coverage passed. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-12-TICKET-COMPLETION.md b/docs/execution-evidence/WF-12-TICKET-COMPLETION.md deleted file mode 100644 index 234a4f3..0000000 --- a/docs/execution-evidence/WF-12-TICKET-COMPLETION.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-1 -ticket: WF-12 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# WF-12 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Implemented terminal aggregate workflow receipts and verification. The receipt commits to the canonical normalized snapshot, snapshot file, append-only event journal, exact node set/states, retry/binding history, child final/completion digests, input bindings, and revalidated canonical approval evidence. - -## Files changed - -- `src/agent_workflow/workflow_receipt.py`, workflow service/CLI seal and verify paths -- `schemas/workflow-receipt.schema.json` -- `tests/test_workflow_receipt.py` - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| Snapshot and event-log digests | pass | receipt rebuild verifies both digests and event count | -| Exact node/run/child/approval evidence | pass | node set and evidence rebuilt from durable state | -| Substitution, omission, duplication, and partial workflows fail | pass | explicit negative tests | -| Post-seal approval tampering fails verification | pass | canonical lifecycle evidence is revalidated during rebuild | - -## Tests and validation - -`PYTHONPATH=src python3 -m pytest -q tests/test_workflow_receipt.py tests/test_workflow.py` — focused receipt/replay coverage passed. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-20-TICKET-COMPLETION.md b/docs/execution-evidence/WF-20-TICKET-COMPLETION.md deleted file mode 100644 index 4efb033..0000000 --- a/docs/execution-evidence/WF-20-TICKET-COMPLETION.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-2 -ticket: WF-20 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# WF-20 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Implemented only the three authorized deterministic graph-template expansions: pipeline, bounded parallel review with fan-in, and implementation followed by independent review. Expansion returns the same canonical workflow snapshot contract used by hand-authored workflows. - -## Files changed - -- `src/agent_workflow/workflow_templates.py` -- workflow template CLI parsing/help -- `tests/test_workflow_templates.py` -- command reference and workflow man page - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| All three authorized shapes | pass | each topology has structural assertions | -| Deterministic canonical expansion | pass | repeated expansion is byte-equivalent after canonical serialization | -| Invalid parameters fail closed | pass | identifiers, counts, and required fields tested | -| No methodology/persona catalog | pass | implementation exposes no extra template registry | - -## Tests and validation - -`PYTHONPATH=src python3 -m pytest -q tests/test_workflow_templates.py tests/test_cli_parsing.py` — focused template/CLI coverage passed. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-21-TICKET-COMPLETION.md b/docs/execution-evidence/WF-21-TICKET-COMPLETION.md deleted file mode 100644 index 57ba434..0000000 --- a/docs/execution-evidence/WF-21-TICKET-COMPLETION.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-2 -ticket: WF-21 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# WF-21 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Implemented deterministic pure routing advice over bounded task metadata. Advice recommends the existing exploratory/review/implementation classes and their configured executor/model/interactivity, while launch-time configuration and no-go policy remain authoritative. - -## Files changed - -- `src/agent_workflow/routing.py` -- `schemas/routing-advice.schema.json` -- scheduler records recommendation, enforced selection, actual selection, explanation codes, and disagreements -- `tests/test_routing.py` - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| Stable rule/explanation output | pass | deterministic repeated-call tests | -| Recommendation and enforcement remain separate | pass | disagreement fields tested | -| No-go policy fails closed | pass | all-no-go candidate test | -| No online learning, embeddings, or config mutation | pass | pure service; no persistence or alternate launcher | - -## Tests and validation - -`PYTHONPATH=src python3 -m pytest -q tests/test_routing.py tests/test_session_launch.py` — focused routing/policy coverage passed. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WF-22-TICKET-COMPLETION.md b/docs/execution-evidence/WF-22-TICKET-COMPLETION.md deleted file mode 100644 index deee3ed..0000000 --- a/docs/execution-evidence/WF-22-TICKET-COMPLETION.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: chatgpt-workflow-completion-next -phase: phase-2 -ticket: WF-22 -session: local-chatgpt-execution-20260724 -result: completed -base_revision: c219eb7 -head_revision: 2ed7b8e ---- - -# WF-22 Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| -| agent-workflow | `c219eb7` | `2ed7b8e` | yes; source archive contained intentional workflow-preparation changes | - -## Scope delivered - -Performed the cumulative workflow/security/documentation integration review. Closed mutable authority leaks in approval creation, approval consumption, child binding, aggregate receipts, retry accounting, and snapshot substitution. Removed the unused vendored MCP SDK, refreshed every current documentation surface, added man pages and a 22-diagram chart pack, and left MCP mutations as a separately authorized backlog item. - -## Files changed - -- cumulative runtime, schema, test, docs, skill, CLI/help, man, backlog, release, and cleanup changes -- `docs/diagrams/REPOSITORY_CHART_PACK.md` -- `CLEANUP_AND_REMOVAL_AUDIT.md`, `FEATURE_TEST_LEDGER.md` -- final gate reports in this directory - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| No alternate launch path | pass | scheduler invokes the existing `sessions.launch` path only | -| No hidden mutable authority | pass | sealed/canonical evidence used for approvals, bindings, retry metrics, and workflow receipts | -| No external-project terminology | pass | repository-wide stale/drift scan | -| Docs/backlog/skills/help/man are current | pass | local link, parser, version, and stale-claim audits | -| Simplification review | pass | 141-file unused vendored SDK subtree removed | - -## Tests and validation - -Final grouped suite and release commands are recorded in `WORKFLOW_BENCHMARK_PHASE_GATE.md`. - -## Tests intentionally not added - -No broad snapshot suite, local-user-file assertions, live paid-provider calls, or duplicate CLI-help tests were added. Coverage targets authority, replay, schema, tamper, size, and accounting seams. - -## Migration and compatibility notes - -Release 0.2.0 adds versioned optional fields/artifacts without rewriting older sealed runs. New runs record retry lineage in provenance. Workflow snapshots are immutable once started; substituted snapshots are rejected. MCP mutation tools remain unimplemented. - -## Unresolved issues or source contradictions - -A fresh independent Codex/Claude executable was unavailable in this environment. Historical independent review evidence exists for WF-00 through WF-02 and identified the original WF-10 flaw; the corrected cumulative tree was instead subjected to isolated critical review and executable tamper tests. BKL-004 real-executor cohorts remain operator-run. - -## No-drift declaration - -- [x] No unrelated external-project behavior was added. -- [x] No superfluous test class was added. -- [x] No live target collection or paid-provider cohort was performed. -- [x] No alternate scheduler/launcher, broker, daemon, database, or HTTP service was added. -- [x] Current documentation claims were checked against source and the live parser. diff --git a/docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md b/docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md deleted file mode 100644 index e082d9a..0000000 --- a/docs/execution-evidence/WORKFLOW_BENCHMARK_PHASE_GATE.md +++ /dev/null @@ -1,56 +0,0 @@ -> **Superseded review notice:** This 0.2.0 gate is historical. The 0.2.1 critical repair in `FINAL_CRITICAL_REVIEW.md` invalidated its claim that no correctness defect remained. - ---- -schema: agent-workflow/phase-gate/v1 -pack_id: chatgpt-workflow-completion-next -phase: cumulative-workflow-and-provider-evidence -review_session: local-critical-review-20260724 -decision: accepted_with_follow_up ---- - -# Workflow and Benchmark Phase Gate - -## Ticket status - -| Ticket | Branch/commit | Review result | Notes | -|---|---|---|---| -| WF-00 / WF-01 / WF-02 | historical accepted commits through `ea77a74` | accepted | Existing workflow contracts, restart-safe scheduler, and recovery CLI were reverified. | -| WF-10 | corrected in `2ed7b8e` | accepted after correction | Original delegated implementation was rejected for trusting a mutable receipt path. Canonical chain reconstruction and sealed lifecycle-authority hardening replace it. | -| WF-11 | `2ed7b8e` | accepted | Bounded immutable result bindings and replay idempotency verified. | -| WF-12 | `2ed7b8e` | accepted | Aggregate receipt rebuild and tamper cases verified. | -| WF-20 / WF-21 | `2ed7b8e` | accepted | Exactly three deterministic templates; routing remains advisory. | -| WF-22 | `2ed7b8e` plus final evidence commit | accepted | Integration/security/docs/cleanup/release review completed. | -| BKL-003-RESEARCH / BKL-003 | `2ed7b8e` | accepted | Primary-source design, bounded provider evidence, trial collection, and comparison implemented. | -| BKL-004 | backlog | deferred | No paid/live real-executor cohort was run or claimed. | - -## Independent gate commands - -| Command | Result summary | -|---|---| -| Focused workflow/provider/lifecycle/evaluation suites | Final complete suite: 212 passed, 1 optional MCP protocol test skipped, and 49 subtests passed in 40.91 seconds. | -| `bash -n install.sh uninstall.sh scripts/*.sh prompt-packs/*/scripts/*.sh` | passed | -| Python compile and all JSON schema parses | passed | -| Live CLI and MCP `--help` probes | passed; MCP `--repo-root` documentation corrected | -| Prompt-pack checksum and validation for both changed packs | passed; workflow pack 4 phases/11 tasks, MCP pack 4 phases/12 tasks | -| `python3 scripts/audit-release-assets.py --write-manifest` and `python3 scripts/audit-release-assets.py` | passed; release assets valid | - -## Boundary audit - -- [x] Authority and ownership boundaries remain intact. -- [x] No alternate launch service, scheduler, daemon, broker, database, or HTTP transport was added. -- [x] Mutable status projections do not control lifecycle receipt creation or workflow approval decisions. -- [x] No unsupported provider values, model winner, paid cohort, or cost inference is claimed. -- [x] Tests correspond to replay, tamper, schema, limits, accounting, and policy contracts. -- [x] Documentation, skills, backlog, CLI reference, and man pages describe implemented behavior. -- [x] External-project terminology and local-directory references were removed from current surfaces. - -## Rejected or deferred work - -- MCP mutation tools remain `MCP-003`, ready but not implemented. -- Destructive/review MCP tools and HTTP remain separately gated. -- BKL-004 remains the operator-run real-executor cohort. -- A fresh external Codex/Claude reviewer was unavailable. Historical independent reviews are retained, and the cumulative correction was reviewed locally with executable negative/tamper tests. No independent receipt is fabricated. - -## Decision rationale - -The cumulative implementation is accepted for release 0.2.0. The decision is `accepted_with_follow_up` only because the prompt pack required a fresh independent external-agent review and no suitable executor binary was available in this environment. That follow-up does not hide a known failing local gate; it is an explicit provenance limitation. diff --git a/docs/man/agent-workflow-mcp.1 b/docs/man/agent-workflow-mcp.1 index 118c8b8..87b0321 100644 --- a/docs/man/agent-workflow-mcp.1 +++ b/docs/man/agent-workflow-mcp.1 @@ -1,4 +1,4 @@ -.TH AGENT-WORKFLOW-MCP 1 "2026-07-24" "agent-workflow 0.2.1" "User Commands" +.TH AGENT-WORKFLOW-MCP 1 "2026-07-25" "agent-workflow 0.2.2" "User Commands" .SH NAME agent-workflow-mcp \- optional local stdio MCP adapter .SH SYNOPSIS @@ -14,4 +14,4 @@ Access is restricted to configured roots with identifier, traversal, symlink, re .SH DEPENDENCY Install the optional mcp extra. The SDK is not vendored; the selected dependency is mcp==1.28.1. .SH SEE ALSO -agent-workflow(1), docs/MCP_SERVER_IMPLEMENTATION_REPORT.md, docs/MCP_SERVER_IMPLEMENTATION_PROPOSAL.md, docs/MCP_THREAT_MODEL.md +agent-workflow(1), docs/MCP_SERVER.md, docs/ARCHITECTURE.md, SECURITY.md diff --git a/docs/man/agent-workflow-workflow.1 b/docs/man/agent-workflow-workflow.1 index a20e4b5..4f86f9c 100644 --- a/docs/man/agent-workflow-workflow.1 +++ b/docs/man/agent-workflow-workflow.1 @@ -1,4 +1,4 @@ -.TH AGENT-WORKFLOW-WORKFLOW 1 "2026-07-24" "agent-workflow 0.2.1" "User Commands" +.TH AGENT-WORKFLOW-WORKFLOW 1 "2026-07-25" "agent-workflow 0.2.2" "User Commands" .SH NAME agent-workflow-workflow \- restart-safe workflow graph commands .SH SYNOPSIS @@ -37,4 +37,4 @@ Every command after start must supply a snapshot whose normalized SHA-256 matche .SH FILES Within RUN_DIR: workflow-snapshot.json, workflow-events.jsonl, workflow-status.json, workflow-run.json, bindings/, and optional workflow-receipt.json. .SH SEE ALSO -agent-workflow(1), docs/WORKFLOW_FOUNDATIONS_PLAN.md, docs/diagrams/REPOSITORY_CHART_PACK.md +agent-workflow(1), docs/ARCHITECTURE.md, docs/diagrams/REPOSITORY_CHART_PACK.md diff --git a/docs/man/agent-workflow.1 b/docs/man/agent-workflow.1 index 096f60d..fd4eee2 100644 --- a/docs/man/agent-workflow.1 +++ b/docs/man/agent-workflow.1 @@ -1,4 +1,4 @@ -.TH AGENT-WORKFLOW 1 "2026-07-24" "agent-workflow 0.2.1" "User Commands" +.TH AGENT-WORKFLOW 1 "2026-07-25" "agent-workflow 0.2.2" "User Commands" .SH NAME agent-workflow \- terminal-first delegated execution, workflow, evidence, and review CLI .SH SYNOPSIS diff --git a/install.sh b/install.sh index db3fc63..07e957c 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,7 @@ mode, including its declared core dependencies, then creates launcher and skill symlinks. Missing dependencies may require network access. Options: + --no-skills Skip installation of agent skill symlinks. --no-deps Skip Python package/dependency installation. --extras NAME[,NAME...] Install optional dependency groups (for example eval,stats or all). Core dependencies are always diff --git a/prompt-packs/agent-workflow-skills-and-mcp/DELEGATION_RUNBOOK.md b/prompt-packs/agent-workflow-skills-and-mcp/DELEGATION_RUNBOOK.md deleted file mode 100644 index 54e2eb5..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/DELEGATION_RUNBOOK.md +++ /dev/null @@ -1,62 +0,0 @@ -# Delegation Runbook - -## Preflight - -```bash -agent-workflow doctor -agent-workflow config show -agent-workflow pack validate /path/to/prompt-pack -agent-workflow worktree create /path/to/repository P0-01 HEAD -``` - -## Launch in a fresh terminal - -```bash -agent-workflow launch project-p0-01-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-01.md --ticket P0-01 --pack project-phases-0-2 --executor codex -agent-workflow launch project-p0-02-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-02.md --ticket P0-02 --pack project-phases-0-2 --executor claude -``` - -The prompt is passed to the command over standard input. - -## Workflow skills - -| Purpose | Codex | Claude | -|---|---|---| -| Build a prompt pack | `$prompt-pack-builder` | `/prompt-pack-builder` | -| Implement one ticket | `$delegated-implementation` | `/delegated-implementation` | -| Review a completed phase | `$phase-gate-review` | `/phase-gate-review` | -| Orchestrate a durable run | `$agent-workflow-orchestrator` | `/agent-workflow-orchestrator` | - -## Observe and foreground - -```bash -agent-workflow list -agent-workflow status project-p0-01 --capture 60 -agent-workflow attach project-p0-01 -agent-workflow tail project-p0-01 -``` - -`possibly_stalled` is advisory. It means tmux is alive while the log has not grown during the configured threshold. - -## Stall handling - -1. Run `status --capture 100`. -2. Attach to the session. -3. Classify input wait, package/network wait, test deadlock, model loop, or legitimate long operation. -4. Interrupt without deleting evidence. -5. Correct the prompt or environment. -6. Restart into a new `-retryN` session. - -## Stop controls - -```bash -agent-workflow interrupt SESSION -agent-workflow terminate SESSION --grace-seconds 8 -agent-workflow kill SESSION -``` - -Use immediate kill only for an unresponsive process. All controls preserve durable evidence. - -## Completion and review - -Require a ticket completion report, inspect the diff before running tests, independently rerun narrow acceptance commands, and create a phase-gate report. A high-risk implementer should not be the only reviewer. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/EXECUTION_PROTOCOL.md b/prompt-packs/agent-workflow-skills-and-mcp/EXECUTION_PROTOCOL.md deleted file mode 100644 index 0c7ba71..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/EXECUTION_PROTOCOL.md +++ /dev/null @@ -1,78 +0,0 @@ -# Execution Protocol - -## 1. Source-of-truth hierarchy - -Use this order when sources disagree: - -1. current checked-out source and argument parsers; -2. current schemas, migrations, and package metadata; -3. tests that exercise current runtime behavior; -4. verified review findings and source excerpts; -5. current README, man page, and skill claims; -6. historical plans and progress notes. - -Never implement a historical “completed” claim without confirming that the behavior exists on the checked-out revision. - -## 2. Required preflight for every ticket - -Record: - -```bash -pwd -git status --short -git rev-parse --show-toplevel -git rev-parse HEAD -git branch --show-current -python3 --version -``` - -For a multi-repository workspace, record the same data for every repository touched. Inspect every path named by the ticket before editing it. If a path has moved, locate the current equivalent and record the mapping; do not recreate removed files merely to match an old prompt. - -## 3. Drift handling - -- If the source matches the reviewed shape, implement the ticket. -- If the source already contains a correct implementation, verify it and limit work to missing acceptance gates. -- If the source partially changed, adapt narrowly and document the delta. -- If the ticket would overwrite newer architecture, schema, or migration work, stop and escalate. -- Never broaden writable paths without explicit authorization. - -## 4. Terminal and observability rule - -Every qualifying delegation runs through `agent-workflow launch` with a named -session identity that includes project, pack, phase, and ticket. Do not invoke -raw tmux lifecycle commands. If the coordinator has a valid current tmux -context, the CLI creates a visible pane in that window; otherwise it creates a -foregroundable detached named session. Each run must write a live persistent -log. - -A delegation is only **potentially** stalled when its terminal is alive and the live log has not changed for the configured interval. Foreground and inspect before interrupting it. Never automatically kill a session merely because a timer elapsed. - -## 5. Implementation discipline - -- Read before editing. -- Make the smallest coherent change. -- Prefer removing a contradictory authorized surface over adding compatibility indirection. -- Do not add a framework, service, database, UI, worker, or build system unless the ticket requires it. -- Do not rename public interfaces outside ticket scope. -- Do not silently change storage formats. -- Use synthetic data and reserved domains such as `example.test`. -- Do not perform live collection unless a separately marked live test explicitly requires it. - -## 6. Test discipline - -Before writing a test, state the failure or contract it protects. Prefer: - -1. pure unit tests for normalization, validation, and migration helpers; -2. contract tests for machine-readable interfaces; -3. one integration test for a complete seam; -4. opt-in live tests under an explicit marker, never as a normal phase exit dependency. - -Do not add tests for line coverage, repeat equivalent CLI-help tests, inspect user-created local databases, depend on absolute home paths, or snapshot broad output when a semantic assertion is sufficient. - -## 7. Completion evidence - -Use `templates/TICKET_COMPLETION.md`. Claims without command output and exit status are not verified. Failed and skipped commands remain visible. Preserve unresolved contradictions rather than inventing certainty. - -## 8. Reviewer protocol - -The reviewer must inspect the complete diff, enforce writable scope, reject unrelated cleanup, independently rerun the smallest gates, inspect migration and secret handling manually, confirm tests map to acceptance criteria, and merge only after dependencies are satisfied. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/MANIFEST.sha256 b/prompt-packs/agent-workflow-skills-and-mcp/MANIFEST.sha256 deleted file mode 100644 index 1f8994b..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/MANIFEST.sha256 +++ /dev/null @@ -1,26 +0,0 @@ -9447bd4c55903c0c4f71297ded3ec6af5f525a63392fb4e8301cac7d131af7c8 DELEGATION_RUNBOOK.md -9d00aca121e09f2eb80e649a2b0588373df4e8188e54349ea78e4e21d61b1049 EXECUTION_PROTOCOL.md -9ed192f7bdf66cd8193517554a434b553a0e534ee1cab1026be8f313a569a7b9 README.md -7c148518377de91eae7f25c71de53b712da0eba9f61b7a9594855d6c2e014629 pack.yaml -756ca0878f48fd8712beaad6cfa1f9fc50cc683ce47e4ea953a96416eb05c06b phase-0/MASTER_IMPLEMENTATION_PROMPT.md -9109e76f3244994fea47f6b0f3f2680723d5d4493a1adb1e8da7538b1fb209fc phase-0/README.md -d8dfdbd532bf31f1640ceb3d6540f8a0ce2e2640b3f433365bc829d0cce2b0bc phase-0/task-manifest.yaml -71979d92fb60472333497b56440163954c1f1e5e65538ef1240713c2f587df28 phase-0/tickets/P0-00-baseline-and-preflight.md -99a890705553271420e05d435ce2edba51be6b8e8c5681f3ce39b634bae8ae44 phase-0/tickets/P0-01-skill-cli-integration.md -d0eb98de8affc3561ef7dbaf9c74401fe833f4c1efabdb2ee2dc6bd616e3843a phase-1/MASTER_IMPLEMENTATION_PROMPT.md -d688128be4505631480a072e5aea3f57d4992e4531d2182329b8925f58d3b941 phase-1/README.md -e368ea14161623cf0966de41a6efeeb5858c3864c392ce695e20f6f5f57fb69a phase-1/task-manifest.yaml -6815f76e700ad26762836bca546de4f256c48f2de4e56886d21291ffe5be53db phase-1/tickets/P1-00-mcp-server-decision.md -4dfde1d611f0b3a959d9d7a6d93430c7fd55a321be3adac63c2ae8b5aa6ec604 references/README.md -a0b6f85ecfc917ea634e2d31901765369c5656a8f588c54594ccc8f7ed340d4b references/code-structure-outlines.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 templates/source-baseline.example.json diff --git a/prompt-packs/agent-workflow-skills-and-mcp/README.md b/prompt-packs/agent-workflow-skills-and-mcp/README.md deleted file mode 100644 index b4b2c58..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Agent-workflow skill integration and MCP decision - -> Status: completed execution input retained for reproducibility. Current repository source and backlog remain authoritative. - - -## Purpose - -Fix P0 BKL-006 so agents can discover and operate `agent-workflow`, then -perform a separate, evidence-backed MCP-server decision study. Phase 0 is the -only code phase. Phase 1 is research only and must not add MCP runtime code. - -## Source baseline - -Supplied source archive, release `0.1.6`, reviewed 2026-07-23. -The checkout and supplied source archive are authoritative when they differ -from a historical reference. - -## Phase map - -| Phase | Objective | Complexity | Exit dependency | -|---|---|---|---| -| 0 | P0 skill/CLI/runbook integration | Medium | Current skill and installer behavior. | -| 1 | MCP SDK/framework/client research and decision | Medium | Phase 0 evidence plus primary sources. | - -## Universal delegation rules - -- Execute every ticket in a fresh named terminal session. -- Use an isolated worktree unless the ticket is explicitly read-only. -- Read required references and current source before editing. -- Follow writable-path restrictions. -- Do not add tests without naming the contract or failure they protect. -- Stop when source contradicts the ticket in a way that could overwrite newer architecture. -- Produce a ticket completion report and preserve all command output. -- Do not treat a host-native subagent as an `agent-workflow` run unless an - explicit bridge invokes the CLI and records receipts. -- Use `agent-workflow launch` rather than raw tmux commands. A valid current - tmux context creates a visible pane; otherwise the CLI creates a detached - named session. - -## How to execute - -See `EXECUTION_PROTOCOL.md`, `DELEGATION_RUNBOOK.md`, and each phase README. -Start with Phase 0. Phase 1 produces only the decision artifact specified by -its ticket; do not implement MCP based on unreviewed research. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/pack.yaml b/prompt-packs/agent-workflow-skills-and-mcp/pack.yaml deleted file mode 100644 index a42695c..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/pack.yaml +++ /dev/null @@ -1,13 +0,0 @@ -schema: agent-workflow/pack/v1 -pack_id: "agent-workflow-skills-and-mcp" -workflow: - name: agent-workflow - minimum_version: "0.1.6" - terminal_backend: tmux - requires: - - isolated_worktree - - foregroundable_session - - persistent_log - - source_baseline - - completion_report - - independent_phase_gate diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-0/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index b71fb5e..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,36 +0,0 @@ -# Phase 0 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by -`task-manifest.yaml`. Complete preflight before P0-01. - -## Objective - -Make `agent-workflow` discoverable and operational to supported agents without -claiming that a host-native subagent is automatically a durable workflow run. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch qualifying delegations through `agent-workflow launch`; do not call - raw tmux lifecycle commands. When the coordinator is in tmux, inspect the - resulting visible pane; otherwise inspect the detached named session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and -produce `PHASE_GATE_REPORT.md`. Do not begin Phase 1 implementation: it is a -research and decision artifact only. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/README.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-0/README.md deleted file mode 100644 index 9a114ea..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Phase 0 — P0 skill and CLI integration - -## Objective - -Fix BKL-006. Create one orchestration skill, update existing workflow skills, -connect the canonical runbook/protocol references, and make the installer skill -discovery policy explicit and tested. Do not implement MCP or alter durable -control semantics. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P0-00 | C | read-only | none | coordinator review | -| P0-01 | B | medium | P0-00 | independent gate | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -## Phase-wide constraints - -Native host subagents are not `agent-workflow` runs unless an explicit bridge -invokes the CLI. Direct all durable launches through `agent-workflow launch`; -do not use raw tmux lifecycle commands. - -## Required references - -- `docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md` -- `BACKLOG.md` BKL-006 -- `docs/COMMAND_REFERENCE.md` and `docs/DELEGATION_LIFECYCLE.md` - -## Exit gate - -Run targeted skill/installer tests, `PYTHONPATH=src pytest -q`, and -`scripts/release-check.sh`. Manually inspect discovery-root ownership behavior -and the skill's decision/tmux language. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/task-manifest.yaml b/prompt-packs/agent-workflow-skills-and-mcp/phase-0/task-manifest.yaml deleted file mode 100644 index 6680227..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/task-manifest.yaml +++ /dev/null @@ -1,12 +0,0 @@ -phase: "0" -name: "skill-cli-integration" -mandatory_order: ["P0-00", "P0-01"] -tasks: - - id: "P0-00" - tier: C - session: "agent-workflow-skills-and-mcp-p0-00-baseline" - prompt: "tickets/P0-00-baseline-and-preflight.md" - - id: "P0-01" - tier: B - session: "agent-workflow-skills-and-mcp-p0-01-integration" - prompt: "tickets/P0-01-skill-cli-integration.md" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-00-baseline-and-preflight.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-00-baseline-and-preflight.md deleted file mode 100644 index ec0e11b..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-00-baseline-and-preflight.md +++ /dev/null @@ -1,52 +0,0 @@ -# P0-00 — Baseline and preflight - -## Delegation metadata - -- Ticket: `P0-00` -- Recommended tier: C -- Dependencies: none -- New terminal session required: yes -- Implementation risk: read-only - -## Objective - -Capture the exact current source baseline and identify material drift from the pack references before implementation begins. - -## Required reading - -- repository root README and package metadata; -- this phase README and master prompt; -- `EXECUTION_PROTOCOL.md`; -- relevant included references. - -## Writable paths - -Only the phase completion/evidence directory designated by the operator. Do not modify production source. - -## Procedure - -1. Record working directory, repository root, branch, revision, and dirty state. -2. Confirm every later ticket path exists or locate its current equivalent. -3. Compare current source with the pack's reviewed baseline. -4. Record contradictions that could invalidate later tickets. -5. Do not solve implementation tickets during this preflight. - -## Acceptance criteria - -- exact revisions and branches are recorded; -- dirty state is recorded; -- path mappings are explicit; -- blocking source contradictions are visible; -- no production source changed. - -## Necessary tests - -No new tests. Validate evidence with Git and filesystem commands only. - -## Stop and escalate conditions - -Stop if the repository or required references cannot be located, the worktree contains unexplained changes, or a later ticket would overwrite clearly newer architecture. - -## Required completion report - -Use `templates/TICKET_COMPLETION.md` and mark this ticket as read-only. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-01-skill-cli-integration.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-01-skill-cli-integration.md deleted file mode 100644 index f55b21d..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-0/tickets/P0-01-skill-cli-integration.md +++ /dev/null @@ -1,54 +0,0 @@ -# P0-01 — Make agent-workflow operationally discoverable - -## Objective - -Implement BKL-006 and `docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md`. A fresh -supported agent must discover a repo-owned orchestration skill, know exactly -when to use the `agent-workflow` executable, and have accurate tmux and -native-subagent boundaries. - -## Required reading - -- `docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md` -- `BACKLOG.md` BKL-006 -- `docs/COMMAND_REFERENCE.md`, `docs/DELEGATION_LIFECYCLE.md`, and - `docs/PROMPT_PACK_STANDARD.md` -- `skills/*/SKILL.md`, `install.sh`, and `tests/test_install_uninstall.py` -- `src/agent_workflow/sessions.py` and `tmux.py` only to verify real behavior - -## Required implementation - -1. Add `skills/agent-workflow-orchestrator/SKILL.md` with a precise trigger - description, decision table, safe command patterns, lifecycle/recovery - steps, tmux current-window behavior, and the semantic-steering limitation. -2. Update the three existing workflow skills to cross-link the orchestration - skill and correct CLI/runbook behavior without duplicating large manuals. -3. Make the installation/discovery policy explicit in `install.sh` and docs. - Detect and test intended Codex, Claude, and shared-agent roots. Preserve - unrelated files and avoid duplicate-name ambiguity. -4. Add narrow tests for skill metadata, install ownership/idempotence, and - canonical command/reference claims. -5. Update `BACKLOG.md` only to mark BKL-006 done after all exit evidence - passes; do not close unrelated tasks. - -## Writable paths - -`skills/`, `install.sh`, `README.md`, `docs/INSTALLATION.md`, -`docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md`, `BACKLOG.md`, narrowly needed -tests, and release manifest. Do not change `src/agent_workflow/` except for a -demonstrated documentation/CLI discovery defect with a focused test. - -## Acceptance criteria - -- A supported agent can locate the skill and execute the documented happy path. -- The skill says native host subagents are not workflow runs unless bridged. -- It directs tmux users to `agent-workflow launch`, never raw tmux creation. -- The installer policy is safe, testable, and documented for each target root. -- Existing skills agree with the canonical command reference. -- Full release checks pass. - -## Stop conditions - -Stop if target-host skill discovery cannot be verified or conflicts between -skill roots cannot be made safe. Record the exact host behavior; do not write -an untested claim or delete user-owned skills. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-1/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 53ba0ec..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,36 +0,0 @@ -# Phase 1 Master Research Prompt - -## Role - -Act as the research coordinator. Execute only the read-only research ticket in -`task-manifest.yaml`; no MCP implementation is authorized in this phase. - -## Objective - -Make a decision-quality recommendation for whether and how `agent-workflow` -should expose an MCP server surface. Use primary sources and distinguish facts, -inferences, and recommendations. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Work read-only in a clean source view; create a worktree only if the - operator wants the memo committed separately. -2. Use `agent-workflow launch` only if a durable research run is desired; no - raw tmux lifecycle commands. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require a cited memo and a decision review. Do not add dependencies or runtime -MCP code based on this phase alone. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/README.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-1/README.md deleted file mode 100644 index 1f1e1f1..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Phase 1 — MCP server decision research - -## Objective - -Produce a primary-source decision memo covering MCP SDKs, server frameworks, -clients, transports, authentication, lifecycle, observability, and the safest -incremental path for this application. No product code changes are authorized. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P1-00 | B | architecture | Phase 0 evidence | maintainer decision | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -## Phase-wide constraints - -Use primary sources only; distinguish evidence from recommendation; do not -modify runtime code, add dependencies, or create an MCP server in this phase. - -## Required references - -Read `references/README.md`, the P0 task breakdown, the durable-control -research memo, and the current source archive before drawing conclusions. - -## Exit gate - -Review direct official citations, the distinction between facts/inferences, the -threat model, and the bounded follow-on implementation proposal. No runtime -test can substitute for the decision review. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/task-manifest.yaml b/prompt-packs/agent-workflow-skills-and-mcp/phase-1/task-manifest.yaml deleted file mode 100644 index 52f3560..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/task-manifest.yaml +++ /dev/null @@ -1,8 +0,0 @@ -phase: "1" -name: "mcp-decision-research" -mandatory_order: ["P1-00"] -tasks: - - id: "P1-00" - tier: B - session: "agent-workflow-skills-and-mcp-p1-00-mcp-research" - prompt: "tickets/P1-00-mcp-server-decision.md" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/tickets/P1-00-mcp-server-decision.md b/prompt-packs/agent-workflow-skills-and-mcp/phase-1/tickets/P1-00-mcp-server-decision.md deleted file mode 100644 index a8dfe62..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/phase-1/tickets/P1-00-mcp-server-decision.md +++ /dev/null @@ -1,80 +0,0 @@ -# P1-00 — MCP server architecture decision research - -## Role and constraint - -You are a research lead. Do not modify application runtime code, package -dependencies, schemas, skills, or configuration. Produce a cited Markdown -decision memo at `docs/MCP_SERVER_DECISION.md` plus a concise proposed backlog -update; do not apply the backlog update yourself. - -## Writable paths - -Only `docs/MCP_SERVER_DECISION.md` and a ticket completion report/evidence -directory designated by the operator. No other repository file may change. - -## Question - -Determine whether MCP is the right integration layer for `agent-workflow`, and -if so propose the smallest secure incremental server surface that makes its -durable run lifecycle, evidence, control records, prompt-pack operations, and -tmux-backed launches usable by MCP clients without falsely promising executor -steering delivery. - -## Required research - -Use primary sources only. Compare current MCP specification and official SDKs -for Python, TypeScript, and any officially supported alternatives; relevant -server frameworks; representative MCP clients/hosts; stdio, Streamable HTTP, -and legacy HTTP/SSE transports; authentication/authorization; tool/resource/ -prompt semantics; sampling/elicitation where applicable; cancellation; -progress/logging; session lifecycle; capability negotiation; and deployment -security. Verify version and maintenance status from official sources. - -Evaluate: no MCP server; a thin in-process Python MCP server wrapping existing -domain APIs; a separate local daemon/service; direct tmux exposure; and a -future broker-backed multi-host MCP gateway. - -## Repository constraints - -- `messages.jsonl` and sealed run receipts remain authoritative. -- MCP notifications are advisory; they cannot replace durable replay. -- Never expose arbitrary shell execution, raw `send-keys`, unrestricted path - access, or secrets through a tool. -- A `steer` tool may persist a request but must report it as pending until a - verified executor acknowledgement exists. -- Reuse existing validation, safe identifiers, state-root boundaries, receipts, - and worktree protections. Do not fork lifecycle logic in an MCP handler. -- Single-host local use is the initial deployment assumption. Multi-host, - broker, and external authentication work need explicit authorization. - -## Required memo contents - -1. Executive recommendation with confidence and rejected alternatives. -2. Current architecture inventory, including BKL-006 and durable-control - boundaries. -3. SDK/framework/client matrix: license, maintenance, Python/protocol/transport - coverage, auth hooks, testing ergonomics, operational burden, and fit. -4. Capability map separating resources from mutating tools, including inputs, - outputs, authorization, evidence, idempotency, and excluded unsafe actions. -5. Local-stdio-first transport/deployment recommendation plus any conditional - HTTP evolution, session, cancellation, progress, and error semantics. -6. Threat model: traversal, injection, confused deputy, cross-run access, tmux - visibility, credentials, leakage, denial of service, destructive lifecycle. -7. Phased implementation plan with bounded tickets, tests/evals, rollback, - versioning, and stop conditions. -8. Maintainer decisions required, and direct official sources/access dates. - -## References - -- `docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md` -- `docs/Durable_Orchestration_Delivery_Benchmarks.md` -- `docs/ARCHITECTURE.md`, `docs/COMMAND_REFERENCE.md`, and - `docs/DELEGATION_LIFECYCLE.md` -- `BACKLOG.md` - -## Acceptance criteria - -The memo is actionable without an implementation patch, does not rely on -unofficial SDK claims, maps every proposed operation to durable evidence, and -makes a clear go/no-go recommendation with a minimal first phase. It rejects -any design that bypasses validation or treats tmux text as proof of delivery. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/references/README.md b/prompt-packs/agent-workflow-skills-and-mcp/references/README.md deleted file mode 100644 index 350e85b..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/references/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# References - -The accompanying source archive is the complete source of truth. These paths -are the mandatory high-value entry points; inspect their current equivalents if -the archive revision has drifted. - -- `docs/AGENT_WORKFLOW_SKILL_INTEGRATION_P0.md`: P0 problem, scope, and gates. -- `BACKLOG.md`: BKL-006 and the remaining architecture queue. -- `docs/COMMAND_REFERENCE.md` and `docs/DELEGATION_LIFECYCLE.md`: actual CLI - and lifecycle surfaces to expose through skills or evaluate for MCP. -- `skills/`: current discovery gap and existing role skill boundaries. -- `install.sh` and `tests/test_install_uninstall.py`: current install ownership - behavior. -- `docs/Durable_Orchestration_Delivery_Benchmarks.md`: durable-record and - benchmark constraints relevant to a future MCP server. -- `docs/ARCHITECTURE.md`: package/domain ownership and state boundaries. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/references/code-structure-outlines.md b/prompt-packs/agent-workflow-skills-and-mcp/references/code-structure-outlines.md deleted file mode 100644 index a5314db..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/references/code-structure-outlines.md +++ /dev/null @@ -1,14 +0,0 @@ -# Code Structure Outlines - -For each proposed module or interface, include: - -1. ownership and authority boundary; -2. exact target path; -3. public types/functions and signatures; -4. persistence or wire-format shape; -5. error/outcome taxonomy; -6. migration and compatibility behavior; -7. tests justified by real contracts; -8. explicitly excluded adjacent work. - -Outlines constrain implementation; they are not permission to rewrite unrelated code. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/archive-prompt-pack.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/archive-prompt-pack.sh deleted file mode 100755 index eef4a85..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/archive-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 2 ]] || { echo "usage: $0 PACK_DIR OUTPUT.tar.zst" >&2; exit 2; } -exec agent-workflow pack archive "$1" "$2" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/check-delegation.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/check-delegation.sh deleted file mode 100755 index 65fc1d5..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/check-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow status "$1" --capture 40 diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/create-ticket-worktree.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/create-ticket-worktree.sh deleted file mode 100755 index 262d9e0..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/create-ticket-worktree.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 3 && $# -le 4 ]] || { echo "usage: $0 REPO_PATH TICKET_ID BASE_REF [DEST]" >&2; exit 2; } -args=(worktree create "$1" "$2" "$3") -[[ $# -ge 4 ]] && args+=(--dest "$4") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/foreground-delegation.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/foreground-delegation.sh deleted file mode 100755 index a9cc4d6..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/foreground-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow attach "$1" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/launch-delegation.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/launch-delegation.sh deleted file mode 100755 index 256ca35..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/launch-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 5 ]] || { echo "usage: $0 SESSION WORKDIR PROMPT_FILE -- COMMAND [ARGS...]" >&2; exit 2; } -exec agent-workflow launch "$@" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/restart-delegation.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/restart-delegation.sh deleted file mode 100755 index 65ec25d..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/restart-delegation.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 1 && $# -le 2 ]] || { echo "usage: $0 SESSION [NEW_SESSION]" >&2; exit 2; } -args=(restart "$1") -[[ $# -eq 2 ]] && args+=(--new-session "$2") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/stop-delegation.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/stop-delegation.sh deleted file mode 100755 index 7f1ad48..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/stop-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow terminate "$1" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/scripts/validate-prompt-pack.sh b/prompt-packs/agent-workflow-skills-and-mcp/scripts/validate-prompt-pack.sh deleted file mode 100755 index 1478e7d..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/scripts/validate-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 PACK_DIR" >&2; exit 2; } -exec agent-workflow pack validate "$1" diff --git a/prompt-packs/agent-workflow-skills-and-mcp/templates/PHASE_GATE_REPORT.md b/prompt-packs/agent-workflow-skills-and-mcp/templates/PHASE_GATE_REPORT.md deleted file mode 100644 index 5fbb185..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/templates/PHASE_GATE_REPORT.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -schema: agent-workflow/phase-gate/v1 -pack_id: "" -phase: "" -review_session: "" -decision: "accepted|rejected|accepted_with_follow_up" ---- - -# Phase Gate Report - -## Ticket status - -| Ticket | Branch/commit | Review result | Notes | -|---|---|---|---| - -## Independent gate commands - -| Command | Exit code | Result summary | -|---|---:|---| - -## Boundary audit - -- [ ] Authority and ownership boundaries remain intact. -- [ ] No unexpected data migration or secret exposure occurred. -- [ ] No unsupported flags, paths, or compatibility claims remain in phase scope. -- [ ] Tests correspond to real contracts or failures. -- [ ] Documentation and skills do not claim unimplemented behavior. -- [ ] Changed files stayed inside ticket writable scopes. - -## Rejected or deferred work - -## Decision rationale diff --git a/prompt-packs/agent-workflow-skills-and-mcp/templates/TICKET_COMPLETION.md b/prompt-packs/agent-workflow-skills-and-mcp/templates/TICKET_COMPLETION.md deleted file mode 100644 index 7f45e85..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/templates/TICKET_COMPLETION.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: "" -phase: "" -ticket: "" -session: "" -result: "completed|partial|failed|blocked" -base_revision: "" -head_revision: "" ---- - -# Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| - -## Scope delivered - -Describe only what was actually changed. - -## Files changed - -```text - -``` - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| | pass/fail/not verified | command/file | - -## Tests and validation - -| Command | Exit code | Contract or failure protected | -|---|---:|---| - -## Tests intentionally not added - -Explain why broader unit, snapshot, CLI-help, local-file, or live tests would be redundant or out of scope. - -## Migration and compatibility notes - -State migration behavior, rollback/recovery behavior, and intentionally unsupported legacy paths. - -## Unresolved issues or source contradictions - -Do not hide uncertainties. - -## No-drift declaration - -- [ ] No files outside writable scope changed. -- [ ] No superfluous tests were added. -- [ ] No live target collection was performed. -- [ ] No compatibility layer was added outside the ticket. -- [ ] Documentation claims were verified against current source before implementation. diff --git a/prompt-packs/agent-workflow-skills-and-mcp/templates/source-baseline.example.json b/prompt-packs/agent-workflow-skills-and-mcp/templates/source-baseline.example.json deleted file mode 100644 index 3fcb45a..0000000 --- a/prompt-packs/agent-workflow-skills-and-mcp/templates/source-baseline.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schema": "agent-workflow/source-baseline/v1", - "generated_at": "", - "components": { - "primary": { - "path": "", - "head": "", - "branch": "", - "dirty": false - } - } -} diff --git a/prompt-packs/chatgpt-workflow-completion-next/CHATGPT_HANDOFF_PROMPT.md b/prompt-packs/chatgpt-workflow-completion-next/CHATGPT_HANDOFF_PROMPT.md deleted file mode 100644 index 746cfa1..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/CHATGPT_HANDOFF_PROMPT.md +++ /dev/null @@ -1,78 +0,0 @@ -# ChatGPT implementation handoff - -You are responsible for completing this repository’s remaining workflow and -benchmark tickets. Work until all listed tickets are completed or a genuine -external blocker is documented with reproducible evidence. Do not stop at a -plan, scaffold, or partial green test run. - -## Source and authority - -Read `SESSION_RESTORE.md`, `BACKLOG.md`, `FEATURE_TEST_LEDGER.md`, this pack’s -README, execution protocol, delegation runbook, and every referenced source -and research document before editing. Rebuild the codebase-memory index before -implementation if available. Preserve unrelated dirty changes. The -repository CLI, durable records, sealed receipts, and canonical backlog are -authoritative; terminal output is observational only. - -## Tickets and order - -Complete these exact tickets in order: - -1. WF-10 — receipt-backed approval gates. -2. WF-11 — bounded JSON Pointer result binding. -3. WF-12 — aggregate workflow receipts. -4. WF-20 — the three authorized reusable graph templates. -5. WF-21 — deterministic explainable routing advice. -6. WF-22 — integration, security, documentation, release, and full-suite review. -7. BKL-003-RESEARCH — provider evidence/usage research and implementation design. -8. BKL-003 — sealed provider evidence and usage normalization. - -Use the ticket dependencies and isolated worktrees. Each implementation must -have a separate independent review. Integrate only after a valid -`agent-workflow/completion/v1` handoff, scope check, focused gates, full-suite -gate, and release-audit result. If a review rejects a ticket, correct it and -repeat the review; do not waive the finding. - -## Research and safety - -Use primary official sources for unstable executor/provider facts and record -citations with access dates. Preserve append-only JSONL/control-log and receipt -authority. Never infer approval, steering delivery, usage, cost, or provider -behavior from logs, prose, tmux capture, or keystrokes. Keep MCP mutation work -behind its declared backlog gate. Do not introduce alternate launch paths, -hidden mutable authorities, destructive tools, or speculative abstractions. - -## Mandatory final critical review and repair - -After all eight tickets have been accepted, perform a fresh critical review of -the complete cumulative diff and current source. Check, at minimum: - -- ticket scope, dependency order, and backlog/document consistency; -- workflow state/replay/restart invariants and approval receipt authenticity; -- JSON Pointer bounds, retry lineage, template expansion, and routing policy; -- MCP/CLI shared-service boundaries and no alternate executor path; -- executor-specific Claude/Codex commands, interactive defaults, model/no-go - policy, tmux layout/capacity, naming, and permissions; -- durable messages, cursors, acknowledgements, receipts, provenance, and - idempotency; -- raw usage evidence, delta/cumulative/terminal semantics, cost/null rules, - retry accounting, comparison validity, and research citations; -- security, path traversal, redaction, schema compatibility, release - manifest, packaging exclusions, documentation drift, and regressions. - -Fix every error, drift, stale claim, failed test, or security weakness found -by that review. Rerun focused tests for each repair, the complete test suite, -release audit, pack validation, and available offline smoke checks. Repeat the -review/fix cycle until clean. Finish with a detailed phase-gate report listing -accepted commits, tests and exit codes, unavailable external checks, known -limitations, final version, and archive checksums. - -## Required final deliverables - -- all ticket source changes and tests; -- updated canonical backlog, status/history, architecture/research docs, and - feature ledger; -- valid completion/review/acceptance evidence for every ticket; -- validated prompt pack and source archive checksums; -- a concise final handoff summarizing implementation, verification, and any - explicitly unresolved external gates. diff --git a/prompt-packs/chatgpt-workflow-completion-next/DELEGATION_RUNBOOK.md b/prompt-packs/chatgpt-workflow-completion-next/DELEGATION_RUNBOOK.md deleted file mode 100644 index af541c7..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/DELEGATION_RUNBOOK.md +++ /dev/null @@ -1,67 +0,0 @@ -# Delegation Runbook - -This runbook applies to the full completion sequence in the pack README. -Do not parallelize dependent tickets. Keep implementation and independent -review in separate worktrees and separate agent sessions. At the end, run the -critical-review/fix loop described in `CHATGPT_HANDOFF_PROMPT.md` even if all -ticket-level reviews passed. - -## Preflight - -```bash -agent-workflow doctor -agent-workflow config show -agent-workflow pack validate /path/to/prompt-pack -agent-workflow worktree create /path/to/repository P0-01 HEAD -``` - -## Launch in a fresh terminal - -```bash -agent-workflow launch project-p0-01-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-01.md --ticket P0-01 --pack project-phases-0-2 --executor codex -agent-workflow launch project-p0-02-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-02.md --ticket P0-02 --pack project-phases-0-2 --executor claude -``` - -The prompt is passed to the command over standard input. - -## Workflow skills - -| Purpose | Codex | Claude | -|---|---|---| -| Build a prompt pack | `$prompt-pack-builder` | `/prompt-pack-builder` | -| Implement one ticket | `$delegated-implementation` | `/delegated-implementation` | -| Review a completed phase | `$phase-gate-review` | `/phase-gate-review` | - -## Observe and foreground - -```bash -agent-workflow list -agent-workflow status project-p0-01 --capture 60 -agent-workflow attach project-p0-01 -agent-workflow tail project-p0-01 -``` - -`possibly_stalled` is advisory. It means tmux is alive while the log has not grown during the configured threshold. - -## Stall handling - -1. Run `status --capture 100`. -2. Attach to the session. -3. Classify input wait, package/network wait, test deadlock, model loop, or legitimate long operation. -4. Interrupt without deleting evidence. -5. Correct the prompt or environment. -6. Restart into a new `-retryN` session. - -## Stop controls - -```bash -agent-workflow interrupt SESSION -agent-workflow terminate SESSION --grace-seconds 8 -agent-workflow kill SESSION -``` - -Use immediate kill only for an unresponsive process. All controls preserve durable evidence. - -## Completion and review - -Require a ticket completion report, inspect the diff before running tests, independently rerun narrow acceptance commands, and create a phase-gate report. A high-risk implementer should not be the only reviewer. diff --git a/prompt-packs/chatgpt-workflow-completion-next/EXECUTION_PROTOCOL.md b/prompt-packs/chatgpt-workflow-completion-next/EXECUTION_PROTOCOL.md deleted file mode 100644 index 132cc39..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/EXECUTION_PROTOCOL.md +++ /dev/null @@ -1,73 +0,0 @@ -# Execution Protocol - -## 1. Source-of-truth hierarchy - -Use this order when sources disagree: - -1. current checked-out source and argument parsers; -2. current schemas, migrations, and package metadata; -3. tests that exercise current runtime behavior; -4. verified review findings and source excerpts; -5. current README, man page, and skill claims; -6. historical plans and progress notes. - -Never implement a historical “completed” claim without confirming that the behavior exists on the checked-out revision. - -## 2. Required preflight for every ticket - -Record: - -```bash -pwd -git status --short -git rev-parse --show-toplevel -git rev-parse HEAD -git branch --show-current -python3 --version -``` - -For a multi-repository workspace, record the same data for every repository touched. Inspect every path named by the ticket before editing it. If a path has moved, locate the current equivalent and record the mapping; do not recreate removed files merely to match an old prompt. - -## 3. Drift handling - -- If the source matches the reviewed shape, implement the ticket. -- If the source already contains a correct implementation, verify it and limit work to missing acceptance gates. -- If the source partially changed, adapt narrowly and document the delta. -- If the ticket would overwrite newer architecture, schema, or migration work, stop and escalate. -- Never broaden writable paths without explicit authorization. - -## 4. New-terminal and observability rule - -Every delegation runs in a fresh named `tmux` session. The session name includes project/pack, phase, and ticket identity. The session must be foregroundable and must write a live persistent log. - -A delegation is only **potentially** stalled when its terminal is alive and the live log has not changed for the configured interval. Foreground and inspect before interrupting it. Never automatically kill a session merely because a timer elapsed. - -## 5. Implementation discipline - -- Read before editing. -- Make the smallest coherent change. -- Prefer removing a contradictory authorized surface over adding compatibility indirection. -- Do not add a framework, service, database, UI, worker, or build system unless the ticket requires it. -- Do not rename public interfaces outside ticket scope. -- Do not silently change storage formats. -- Use synthetic data and reserved domains such as `example.test`. -- Do not perform live collection unless a separately marked live test explicitly requires it. - -## 6. Test discipline - -Before writing a test, state the failure or contract it protects. Prefer: - -1. pure unit tests for normalization, validation, and migration helpers; -2. contract tests for machine-readable interfaces; -3. one integration test for a complete seam; -4. opt-in live tests under an explicit marker, never as a normal phase exit dependency. - -Do not add tests for line coverage, repeat equivalent CLI-help tests, inspect user-created local databases, depend on absolute home paths, or snapshot broad output when a semantic assertion is sufficient. - -## 7. Completion evidence - -Use `templates/TICKET_COMPLETION.md`. Claims without command output and exit status are not verified. Failed and skipped commands remain visible. Preserve unresolved contradictions rather than inventing certainty. - -## 8. Reviewer protocol - -The reviewer must inspect the complete diff, enforce writable scope, reject unrelated cleanup, independently rerun the smallest gates, inspect migration and secret handling manually, confirm tests map to acceptance criteria, and merge only after dependencies are satisfied. diff --git a/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 b/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 deleted file mode 100644 index 71ec440..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/MANIFEST.sha256 +++ /dev/null @@ -1,41 +0,0 @@ -7e0976f8f782192607ad0fb6c8b88306d7647228e787f87f7c814267cb4e892e CHATGPT_HANDOFF_PROMPT.md -5bb7334ba2b658d9810f1c0a8780c5eb51acec4597f85d3ddc73fe97935e7ea1 DELEGATION_RUNBOOK.md -adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -7a4f042f4b9c6935c9b2cfee509f0783dc94f20fe7ad335ed0b5d2b4a5d5ec62 README.md -a34bd83458605c47cc4f74aa3c1261dc4f6f185647521e8916738d46508d1276 contracts/workflow-node-result.schema.json -a70069129389ff6c4f8affe3fb94d8b91e8b42b1a72a69dd6bfd45e392f979a5 pack.yaml -f7523df45911ef669bcc812d26f8cef88a1ca8f8aa2f4fe5ac05dbd5a2321247 phase-0/MASTER_IMPLEMENTATION_PROMPT.md -c72159dcbc2f216655a37e25bc1c843219ce0d0d0376369b3902a96f60459e80 phase-0/README.md -da114439c42bc72666375c5f693ad3a975fe2deb931963c3d038de5117f63c2e phase-0/task-manifest.yaml -325f6722e52dda55f8b565640f2dfa71bb25c12a7a44a6c078a1db7ee62416de phase-0/tickets/WF-00-contract-and-state.md -8d0d431572c396e5f580455c459367f84d1ba375da5f74a6230bea37edfe59c5 phase-0/tickets/WF-01-scheduler-service.md -0ed1811b6960deca79bdf16ad6601ecf1f39d5a8d8841c2af001bba42015269d phase-0/tickets/WF-02-restart-and-cli.md -be0c18b729ce86203318c5f0604f800f197026dc38d70336009a97fdbdfa6f05 phase-1/MASTER_IMPLEMENTATION_PROMPT.md -7ce12c4c9bb6ecd96c2cb3210063cf5f8a1fba4e7cbdbec061728e1ba58981f9 phase-1/README.md -1586d81810dca16935cf763d16f15a2f0e526674e60b5eae8a1beaf5d74a179e phase-1/task-manifest.yaml -b595b2a035988910d0e7315852d0f3d9b68b4039d8dd9e8a5a6726794faaf22e phase-1/tickets/WF-10-approval-gates.md -0a6f2bcb59ef8b1abd2a038dbe7344ca78e5872c3b5269e4d09fcf330955e20b phase-1/tickets/WF-11-result-binding.md -2ad9a00fb19ddd2533d6db54c97f1805fc3bf91ad1ec2571cebf17b78ff3839d phase-1/tickets/WF-12-workflow-receipt.md -54511b2b64f1b90dca042b12bea9bed0080a6f89f8b910f2120c24079ae3f17c phase-2/MASTER_IMPLEMENTATION_PROMPT.md -e4b9938f2d00d52f56d288ac97751b7e4972053d36e9c54ad6133164c10d937a phase-2/README.md -b1a5a2c32148f7adb1e6b4df2753823ee7c087d4c8ed5b97dba0e7e31884f800 phase-2/task-manifest.yaml -e4a9948cda12a880e465ddc911f06583f96aaf5f520d8494ec539a9d17974d9b phase-2/tickets/WF-20-templates.md -d4932ccc4b6c4734745451cd76c439b42e78cdd36d7289d020bea44f5e5ddeb3 phase-2/tickets/WF-21-routing-advice.md -504c047b3cb460439975726ca8b891acdd0f5c6b379d196541c4cf5a6ae8064e phase-2/tickets/WF-22-integration-review.md -0480ea0d3b5437b7a5b732786c8b4504ab79d761ed8168168d4c481c366e518e phase-3/MASTER_IMPLEMENTATION_PROMPT.md -7b4150861c904b1288bbb47a488050f9560300322f20627edf4776355f055222 phase-3/README.md -a31211d7aba32b9f13c4ae77b210b66d2678c52618dc5c90f2d7ef5270f41259 phase-3/task-manifest.yaml -1e092212995cce013d5737a3a3a9d1d14d46a8da24e8a0d6816dd603efc31ed6 phase-3/tickets/BKL-003-RESEARCH.md -d10b15b3c1e73f58cecd40c5113f98987061b9efa058739d4c55f5a905ab71a4 phase-3/tickets/BKL-003.md -2276f05f42f628925cd0b0834d7044e5934a488f85f4a57d5b22b023009ae17f references/README.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 templates/source-baseline.example.json diff --git a/prompt-packs/chatgpt-workflow-completion-next/README.md b/prompt-packs/chatgpt-workflow-completion-next/README.md deleted file mode 100644 index d3fdb74..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# ChatGPT Workflow Foundations and Benchmark Completion - -> Status: executed against release 0.2.0; retained as the authoritative implementation input and reproducibility pack. - -This pack is the ordered implementation handoff for ChatGPT. It completes -WF-10, WF-11, WF-12, WF-20, WF-21, WF-22, and BKL-003, including the research -needed to make BKL-003 safe and evidence-backed. Work must continue through -all tickets and the final critical review/fix pass; do not stop after a merely -green local test run. - -The current source checkout and companion source archive are authoritative -when they differ. `BACKLOG.md` remains the canonical task register. - -## Required execution order - -1. Verify WF-00/WF-01/WF-02 and existing MCP/read-only foundations. -2. Complete WF-10 → WF-11 → WF-12. -3. Complete WF-20 → WF-21 → WF-22. -4. Complete BKL-003-RESEARCH, then BKL-003 implementation and evidence gates. -5. Run the mandatory critical review across every changed ticket, fix errors, - drift, stale documentation, manifest defects, security issues, and - regressions, then rerun all gates. - -Every ticket requires an isolated worktree, durable completion evidence, -focused tests, relevant full-suite tests, release-audit validation, and an -independent review before integration. Preserve the existing launch service -as the only execution authority. Do not implement MCP mutation tools unless a -ticket explicitly permits them. - -## Research requirements - -Use the supplied research references first, then verify unstable or provider- -specific facts against primary official documentation. Record URLs, access -dates, assumptions, exclusions, and unresolved questions in repository -research/evidence documents. BKL-003 must not infer usage or cost from prose, -terminal capture, or incomplete provider output. - -## Operational boundary - -Run `agent-workflow pack validate` before execution. Create each worktree with -`agent-workflow worktree create` and launch each bounded ticket with -`agent-workflow launch`. A valid current tmux context creates a visible pane; -an unusable context falls back to a detached named session. Native host -subagents are not durable workflow runs unless explicitly bridged through the -CLI. Claude agents are interactive by default; use `--no-interactive` or -`--structured` only when the ticket explicitly requires detached execution. diff --git a/prompt-packs/chatgpt-workflow-completion-next/contracts/workflow-node-result.schema.json b/prompt-packs/chatgpt-workflow-completion-next/contracts/workflow-node-result.schema.json deleted file mode 100644 index 109006c..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/contracts/workflow-node-result.schema.json +++ /dev/null @@ -1 +0,0 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["implemented","tests","unresolved"],"properties":{"implemented":{"type":"array","items":{"type":"string"}},"tests":{"type":"array","items":{"type":"string"}},"unresolved":{"type":"array","items":{"type":"string"}}},"additionalProperties":false} diff --git a/prompt-packs/chatgpt-workflow-completion-next/pack.yaml b/prompt-packs/chatgpt-workflow-completion-next/pack.yaml deleted file mode 100644 index c2c1c76..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/pack.yaml +++ /dev/null @@ -1,9 +0,0 @@ -schema: agent-workflow/pack/v1 -pack_id: chatgpt-workflow-completion-next -name: ChatGPT Workflow Foundations and Benchmark Completion -version: 1 -phases: - - phase-0 - - phase-1 - - phase-2 - - phase-3 diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/chatgpt-workflow-completion-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index ee998ec..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Implement Phase 0 tickets in dependency order. Reuse existing state, launch, lifecycle, and receipt services. The workflow event journal is append-only and authoritative; any status snapshot must be reconstructable. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-0/README.md b/prompt-packs/chatgpt-workflow-completion-next/phase-0/README.md deleted file mode 100644 index 94012ad..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-0/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Phase 0 — Scheduler core - -Implement the smallest restart-safe dependency scheduler over validated prompt packs. No approval, templates, or learned routing in this phase. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-0/task-manifest.yaml b/prompt-packs/chatgpt-workflow-completion-next/phase-0/task-manifest.yaml deleted file mode 100644 index 2d75ee7..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-0/task-manifest.yaml +++ /dev/null @@ -1,27 +0,0 @@ -phase: 0 -name: scheduler-core -mandatory_order: [WF-00, WF-01, WF-02] -tasks: - - id: WF-00 - tier: B - session: workflow-foundations-wf-00 - prompt: tickets/WF-00-contract-and-state.md - result_contract: - schema: contracts/workflow-node-result.schema.json - required: true - - id: WF-01 - tier: A - session: workflow-foundations-wf-01 - prompt: tickets/WF-01-scheduler-service.md - dependencies: [WF-00] - result_contract: - schema: contracts/workflow-node-result.schema.json - required: true - - id: WF-02 - tier: A - session: workflow-foundations-wf-02 - prompt: tickets/WF-02-restart-and-cli.md - dependencies: [WF-01] - result_contract: - schema: contracts/workflow-node-result.schema.json - required: true diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-00-contract-and-state.md b/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-00-contract-and-state.md deleted file mode 100644 index 9bdb037..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-00-contract-and-state.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-00 — Workflow contract and durable state - -Define the minimal workflow-run schema, immutable pack snapshot, node/run binding, append-only event envelope, and reconstructable status projection. Writable: workflow schemas, workflow domain module, tests, directly related docs. Acceptance: unknown/cyclic graphs remain rejected; status can be rebuilt solely from snapshot plus events; no mutable store becomes authoritative. Test all transitions and corruption handling. Stop if implementation requires arbitrary workflow code or a second executor path. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-01-scheduler-service.md b/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-01-scheduler-service.md deleted file mode 100644 index ab6f19a..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-01-scheduler-service.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-01 — Dependency scheduler service - -Implement eligibility calculation, bounded parallel launch planning, dependency failure propagation, and retry lineage. Invoke the existing launch domain service; do not shell out to the CLI or spawn executors directly. Writable: workflow service, narrow launch seam refactor if required, tests, docs. Acceptance: each eligible node launches at most once per binding; restart replay is idempotent; parallelism is bounded; failed prerequisites block dependents. Stop on policy ambiguity. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-02-restart-and-cli.md b/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-02-restart-and-cli.md deleted file mode 100644 index f6c6fd2..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-0/tickets/WF-02-restart-and-cli.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-02 — Restart recovery and CLI - -Add minimal `workflow validate`, `workflow start`, `workflow status`, and `workflow resume` commands over the scheduler service. Writable: CLI, workflow service, tests, docs. Acceptance: JSON output is stable and bounded; resume reconstructs state before scheduling; CLI and future MCP adapters share the same service; no HTTP or daemon is added. Test restart, duplicate invocation, invalid roots, and terminal workflows. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/chatgpt-workflow-completion-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 94cc64c..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Implement receipt-backed approval gates, downstream JSON Pointer binding, and an aggregate workflow receipt. Keep all inputs bounded and provenance-sealed. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-1/README.md b/prompt-packs/chatgpt-workflow-completion-next/phase-1/README.md deleted file mode 100644 index 4b62fbd..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-1/README.md +++ /dev/null @@ -1 +0,0 @@ -# Phase 1 — Evidence gates and result binding diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-1/task-manifest.yaml b/prompt-packs/chatgpt-workflow-completion-next/phase-1/task-manifest.yaml deleted file mode 100644 index 479ab6f..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-1/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: 1 -name: gates-and-binding -mandatory_order: [WF-10, WF-11, WF-12] -tasks: - - id: WF-10 - tier: A - session: workflow-foundations-wf-10 - prompt: tickets/WF-10-approval-gates.md - dependencies: [WF-02] - - id: WF-11 - tier: A - session: workflow-foundations-wf-11 - prompt: tickets/WF-11-result-binding.md - dependencies: [WF-10] - - id: WF-12 - tier: A - session: workflow-foundations-wf-12 - prompt: tickets/WF-12-workflow-receipt.md - dependencies: [WF-11] diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-10-approval-gates.md b/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-10-approval-gates.md deleted file mode 100644 index b3f2cea..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-10-approval-gates.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-10 — Receipt-backed approval gates - -Add an approval node satisfied only by a valid review/lifecycle receipt referencing the expected child final-receipt digest. No mutable approval boolean is authoritative. Acceptance: accepted and rejected paths are distinct, tampered or unrelated receipts fail closed, and downstream eligibility follows durable evidence. Update tests and docs. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-11-result-binding.md b/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-11-result-binding.md deleted file mode 100644 index 8ab5b08..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-11-result-binding.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-11 — Structured result binding - -Allow declared downstream inputs to select fields from validated predecessor `result.json` using JSON Pointer. Snapshot resolved values and source digests into child provenance. Enforce size limits and fail closed on missing required fields. Do not add an expression or template language. Test malformed pointers, oversized values, absent results, and retry lineage. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-12-workflow-receipt.md b/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-12-workflow-receipt.md deleted file mode 100644 index 347e3f2..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-1/tickets/WF-12-workflow-receipt.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-12 — Aggregate workflow receipt - -Seal a terminal workflow receipt containing the workflow snapshot digest, event-log digest, node IDs, bound run IDs, child final-receipt digests, approval receipt digests, and terminal outcome. Provide verification. Test substitution, omission, duplicate nodes, and partial workflows. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/chatgpt-workflow-completion-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 20a3da3..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Add only three graph templates and deterministic routing explanations. No learning system, personas, or hidden hooks. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-2/README.md b/prompt-packs/chatgpt-workflow-completion-next/phase-2/README.md deleted file mode 100644 index a9722ee..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-2/README.md +++ /dev/null @@ -1 +0,0 @@ -# Phase 2 — Small templates and routing explanations diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-2/task-manifest.yaml b/prompt-packs/chatgpt-workflow-completion-next/phase-2/task-manifest.yaml deleted file mode 100644 index 3e0acf5..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-2/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: 2 -name: templates-and-routing -mandatory_order: [WF-20, WF-21, WF-22] -tasks: - - id: WF-20 - tier: B - session: workflow-foundations-wf-20 - prompt: tickets/WF-20-templates.md - dependencies: [WF-12] - - id: WF-21 - tier: A - session: workflow-foundations-wf-21 - prompt: tickets/WF-21-routing-advice.md - dependencies: [WF-20] - - id: WF-22 - tier: A - session: workflow-foundations-wf-22 - prompt: tickets/WF-22-integration-review.md - dependencies: [WF-21] diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-20-templates.md b/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-20-templates.md deleted file mode 100644 index a017bce..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-20-templates.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-20 — Three reusable workflow templates - -Implement validated expansion for pipeline, parallel-review/fan-in, and implementation-plus-independent-review. Expansion must produce the canonical workflow schema. Do not add methodology or agent-persona catalogs. Test deterministic expansion and invalid parameters. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-21-routing-advice.md b/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-21-routing-advice.md deleted file mode 100644 index 59f0595..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-21-routing-advice.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-21 — Deterministic routing advice - -Implement a pure advisory function over existing task metadata that recommends current agent class, executor, model, and interactive mode with stable explanation codes. Existing configuration enforcement remains authoritative. Record recommendation, enforced selection, and policy disagreement separately. No embeddings, online learning, or config mutation. Test all rules and no-go rejection. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-22-integration-review.md b/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-22-integration-review.md deleted file mode 100644 index 4086203..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-2/tickets/WF-22-integration-review.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-22 — Critical integration review - -Review scheduler, gates, binding, templates, and routing against current tmux, interactive session, naming, model policy, MCP, receipts, and global routing contracts. Remove speculative abstractions and stale docs. Run the full suite and release audit. Acceptance: no alternate launch path, no hidden mutable authority, no external-project terminology, and all backlog states/evidence are current. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/chatgpt-workflow-completion-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 5ef8616..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,8 +0,0 @@ -# Phase 3 master prompt — BKL-003 - -Complete BKL-003-RESEARCH before BKL-003. Research must produce cited, -primary-source evidence and a bounded design. Implementation must preserve -raw provider evidence, explicit usage semantics, null/unknown cost rules, -idempotency, retry accounting, and sealed receipts. No paid cohort may be -claimed without sealed provider evidence. Require focused tests, full suite, -release audit, independent review, and final critical review. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-3/README.md b/prompt-packs/chatgpt-workflow-completion-next/phase-3/README.md deleted file mode 100644 index 3ce842b..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-3/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Phase 3 — Benchmark evidence and usage correctness - -Research and implement BKL-003 only after WF-22 has accepted the workflow -foundation. The research ticket is read-only except for durable research -artifacts; the implementation ticket may change provider evidence adapters, -schemas, tests, docs, and release assets within its declared scope. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-3/task-manifest.yaml b/prompt-packs/chatgpt-workflow-completion-next/phase-3/task-manifest.yaml deleted file mode 100644 index 2a7b10e..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-3/task-manifest.yaml +++ /dev/null @@ -1,14 +0,0 @@ -phase: 3 -name: benchmark-evidence -mandatory_order: [BKL-003-RESEARCH, BKL-003] -tasks: - - id: BKL-003-RESEARCH - tier: A - session: chatgpt-bkl-003-research - prompt: tickets/BKL-003-RESEARCH.md - dependencies: [WF-22] - - id: BKL-003 - tier: A - session: chatgpt-bkl-003 - prompt: tickets/BKL-003.md - dependencies: [BKL-003-RESEARCH] diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003-RESEARCH.md b/prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003-RESEARCH.md deleted file mode 100644 index c3d95d2..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003-RESEARCH.md +++ /dev/null @@ -1,30 +0,0 @@ -# BKL-003-RESEARCH — provider evidence and usage envelope - -Perform a bounded research phase before implementation. Read -`BACKLOG.md`, `docs/Durable_Orchestration_Delivery_Benchmarks.md`, -`docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md`, `docs/ARCHITECTURE.md`, the -current executor adapters, event schemas, receipts, and evaluation code. - -Verify current official primary-source behavior for every supported executor -and model surface that can emit usage, cost, retry, or stream events. Record -source URLs/access dates and distinguish facts from inference. Define the -minimum evidence contract for raw immutable provider events and sealed hashes; -`delta`, `cumulative`, and `terminal` usage semantics; cached/reasoning/input/ -output token fields; retries, restarts, duplicate events, missing telemetry, -and partial runs; provider-billed versus locally estimated cost, currency, -price-catalog version, and unknown/null handling; and schema compatibility -and comparison exclusions. - -Produce a durable research memo and implementation checklist. Do not change -runtime behavior in this ticket. Validate references and run only read-only -structural checks. Stop if a required provider fact cannot be verified; -document the gap instead of inventing a value. - -Completion requires a valid handoff, exact commands, source list, explicit -open questions, and an independent research review before BKL-003 begins. - -Writable paths: research memo, evidence references, and directly related -planning documentation only. Acceptance: cited primary sources, explicit -facts/inferences/open questions, and no runtime changes. Stop on unverifiable -provider claims. Tests: validate the pack, verify every cited reference path, -and run the repository’s read-only structural checks. diff --git a/prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003.md b/prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003.md deleted file mode 100644 index f4d0b80..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/phase-3/tickets/BKL-003.md +++ /dev/null @@ -1,30 +0,0 @@ -# BKL-003 — sealed provider evidence and usage normalization - -Implement the accepted BKL-003 research design. Preserve raw bounded executor -stream evidence before normalization and seal its digest into the existing -receipt/evaluation evidence chain. Add provider adapters that explicitly label -each usage measurement as `delta`, `cumulative`, or `terminal`; never sum -mixed modes. Make duplicate/replayed events idempotent and preserve retry, -restart, and incomplete-trial accounting. - -Token and cost values must remain null when they cannot be proven from sealed -provider output or a versioned price catalog. Keep provider-billed and local -estimate fields distinct, record currency and catalog identity, and reject -incomplete or internally inconsistent trials. Do not add online learning, -vector memory, a broker, a daemon, or a new alternate launch path. - -Add versioned schemas, focused tests for each usage mode and failure case, -receipt/hash tests, comparison/evaluation tests, documentation, backlog -state, and regenerated release assets. Run focused tests, the full suite, -release audit, and any deterministic offline cohort checks supported by the -environment. External paid-provider cohorts remain operator-run evidence -gates; do not claim they ran if they did not. - -Completion requires a valid sealed handoff, exact evidence commands, an -independent review, and explicit exclusions for unavailable provider data. - -Writable paths: provider evidence adapters, related schemas/tests, receipts, -evaluation code, docs, backlog/status artifacts, and release manifests only. -Acceptance: all usage-mode, replay, retry, null-cost, receipt, comparison, -full-suite, and release-audit gates pass. Stop rather than inventing provider -telemetry or adding an alternate launch path. diff --git a/prompt-packs/chatgpt-workflow-completion-next/references/README.md b/prompt-packs/chatgpt-workflow-completion-next/references/README.md deleted file mode 100644 index 20caa04..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/references/README.md +++ /dev/null @@ -1,9 +0,0 @@ -Read the repository copies of: - -- `docs/WORKFLOW_FOUNDATIONS_PLAN.md` -- `docs/GLOBAL_AGENT_ROUTING.md` -- `docs/ARCHITECTURE.md` -- `BACKLOG.md` -- the current prompt-pack, runner, receipts, lifecycle, config, and session code - -The extracted source is authoritative over this pack. diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/archive-prompt-pack.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/archive-prompt-pack.sh deleted file mode 100755 index eef4a85..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/archive-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 2 ]] || { echo "usage: $0 PACK_DIR OUTPUT.tar.zst" >&2; exit 2; } -exec agent-workflow pack archive "$1" "$2" diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/check-delegation.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/check-delegation.sh deleted file mode 100755 index 65fc1d5..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/check-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow status "$1" --capture 40 diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/create-ticket-worktree.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/create-ticket-worktree.sh deleted file mode 100755 index 262d9e0..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/create-ticket-worktree.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 3 && $# -le 4 ]] || { echo "usage: $0 REPO_PATH TICKET_ID BASE_REF [DEST]" >&2; exit 2; } -args=(worktree create "$1" "$2" "$3") -[[ $# -ge 4 ]] && args+=(--dest "$4") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/foreground-delegation.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/foreground-delegation.sh deleted file mode 100755 index a9cc4d6..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/foreground-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow attach "$1" diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/launch-delegation.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/launch-delegation.sh deleted file mode 100755 index 256ca35..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/launch-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 5 ]] || { echo "usage: $0 SESSION WORKDIR PROMPT_FILE -- COMMAND [ARGS...]" >&2; exit 2; } -exec agent-workflow launch "$@" diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/restart-delegation.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/restart-delegation.sh deleted file mode 100755 index 65ec25d..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/restart-delegation.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 1 && $# -le 2 ]] || { echo "usage: $0 SESSION [NEW_SESSION]" >&2; exit 2; } -args=(restart "$1") -[[ $# -eq 2 ]] && args+=(--new-session "$2") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/stop-delegation.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/stop-delegation.sh deleted file mode 100755 index 7f1ad48..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/stop-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow terminate "$1" diff --git a/prompt-packs/chatgpt-workflow-completion-next/scripts/validate-prompt-pack.sh b/prompt-packs/chatgpt-workflow-completion-next/scripts/validate-prompt-pack.sh deleted file mode 100755 index 1478e7d..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/scripts/validate-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 PACK_DIR" >&2; exit 2; } -exec agent-workflow pack validate "$1" diff --git a/prompt-packs/chatgpt-workflow-completion-next/templates/PHASE_GATE_REPORT.md b/prompt-packs/chatgpt-workflow-completion-next/templates/PHASE_GATE_REPORT.md deleted file mode 100644 index 5fbb185..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/templates/PHASE_GATE_REPORT.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -schema: agent-workflow/phase-gate/v1 -pack_id: "" -phase: "" -review_session: "" -decision: "accepted|rejected|accepted_with_follow_up" ---- - -# Phase Gate Report - -## Ticket status - -| Ticket | Branch/commit | Review result | Notes | -|---|---|---|---| - -## Independent gate commands - -| Command | Exit code | Result summary | -|---|---:|---| - -## Boundary audit - -- [ ] Authority and ownership boundaries remain intact. -- [ ] No unexpected data migration or secret exposure occurred. -- [ ] No unsupported flags, paths, or compatibility claims remain in phase scope. -- [ ] Tests correspond to real contracts or failures. -- [ ] Documentation and skills do not claim unimplemented behavior. -- [ ] Changed files stayed inside ticket writable scopes. - -## Rejected or deferred work - -## Decision rationale diff --git a/prompt-packs/chatgpt-workflow-completion-next/templates/TICKET_COMPLETION.md b/prompt-packs/chatgpt-workflow-completion-next/templates/TICKET_COMPLETION.md deleted file mode 100644 index 7f45e85..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/templates/TICKET_COMPLETION.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: "" -phase: "" -ticket: "" -session: "" -result: "completed|partial|failed|blocked" -base_revision: "" -head_revision: "" ---- - -# Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| - -## Scope delivered - -Describe only what was actually changed. - -## Files changed - -```text - -``` - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| | pass/fail/not verified | command/file | - -## Tests and validation - -| Command | Exit code | Contract or failure protected | -|---|---:|---| - -## Tests intentionally not added - -Explain why broader unit, snapshot, CLI-help, local-file, or live tests would be redundant or out of scope. - -## Migration and compatibility notes - -State migration behavior, rollback/recovery behavior, and intentionally unsupported legacy paths. - -## Unresolved issues or source contradictions - -Do not hide uncertainties. - -## No-drift declaration - -- [ ] No files outside writable scope changed. -- [ ] No superfluous tests were added. -- [ ] No live target collection was performed. -- [ ] No compatibility layer was added outside the ticket. -- [ ] Documentation claims were verified against current source before implementation. diff --git a/prompt-packs/chatgpt-workflow-completion-next/templates/source-baseline.example.json b/prompt-packs/chatgpt-workflow-completion-next/templates/source-baseline.example.json deleted file mode 100644 index 3fcb45a..0000000 --- a/prompt-packs/chatgpt-workflow-completion-next/templates/source-baseline.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schema": "agent-workflow/source-baseline/v1", - "generated_at": "", - "components": { - "primary": { - "path": "", - "head": "", - "branch": "", - "dirty": false - } - } -} diff --git a/prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md b/prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md index a844b83..9662cd3 100644 --- a/prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md +++ b/prompt-packs/mcp-server-next/CHATGPT_HANDOFF_PROMPT.md @@ -1,34 +1,10 @@ -# ChatGPT handoff prompt — agent-workflow MCP continuation +# ChatGPT handoff — MCP mutation phase -You are the architecture reviewer and bounded implementation coordinator for the -optional `agent-workflow` MCP adapter. Treat current source and `BACKLOG.md` as -authoritative. +Treat current source, `BACKLOG.md`, and the consolidated public documentation as authoritative. -Phases 0 through 2 of this pack are completed. Do not redo them unless current -verification finds a concrete regression. The remaining work is Phase 3 and it -must not begin until the separate `workflow-foundations-next` prompt pack is -complete through canonical backlog task `WF-22`. +1. Read `docs/ARCHITECTURE.md`, `docs/MCP_SERVER.md`, `docs/OPERATIONS.md`, `docs/TESTING.md`, the current CLI/service/MCP code, and this pack. +2. Execute `MCP3-00` to map every proposed tool to one transport-neutral service shared with the CLI. Reject any tool that requires direct state mutation or duplicate scheduling/routing logic. +3. Execute `MCP3-01` for the smallest safe local-stdio mutation surface, preserving configured roots, typed arguments, durable idempotency, stable errors, policy enforcement, and immutable evidence. +4. Execute `MCP3-02` as an independent review. Extend installed-product MCP journeys first; add invariant matrices only for general security, replay, or idempotency boundaries. -Work in this order: - -1. Extract and inventory current source and both prompt packs. -2. Read `BACKLOG.md`, `docs/WORKFLOW_FOUNDATIONS_PLAN.md`, - `docs/MCP_SERVER_DECISION.md`, `docs/MCP_SERVER_IMPLEMENTATION_REPORT.md`, - `docs/GLOBAL_AGENT_ROUTING.md`, and the current workflow/MCP code and tests. -3. Verify completed workflow-foundation evidence and confirm `WF-22` is done. - Stop Phase 3 if it is not. -4. Execute `P3-00` to map every proposed MCP operation to one authoritative - transport-neutral service shared with the CLI. Reject operations that need - direct workflow/run file mutation or duplicate scheduling/routing logic. -5. Implement only the bounded Phase 3 surface authorized by `P3-01`, preserving - configured roots, typed arguments, stable errors, idempotency, restart - evidence, and immutable receipts. -6. Execute independent review `P3-02`, including focused tests, all test modules, - prompt-pack/schema validation, release-asset audit, and official MCP tooling - when available. - -Non-goals: external orchestration runtimes, arbitrary workflow scripts, memory or -learning infrastructure, named persona catalogs, federation, consensus, HTTP/SSE, -OAuth/service deployment, arbitrary shell/file access, direct tmux controls, -raw terminal capture, environment output, force kill, destructive tools, or MCP -Tasks as lifecycle authority. +Do not add HTTP/SSE, OAuth deployment, arbitrary shell/file access, direct tmux controls, raw terminal output, environment dumps, force kill, destructive tools, or MCP-local lifecycle authority. diff --git a/prompt-packs/mcp-server-next/MANIFEST.sha256 b/prompt-packs/mcp-server-next/MANIFEST.sha256 index 3bf8b1c..f7acc92 100644 --- a/prompt-packs/mcp-server-next/MANIFEST.sha256 +++ b/prompt-packs/mcp-server-next/MANIFEST.sha256 @@ -1,34 +1,16 @@ -838a6333e2fd160f71c8d3d6610a6e0521e412f92181f5332f3ceba1f696110b CHATGPT_HANDOFF_PROMPT.md +a9d1763e3b90ed11068b7532bf4d42b24fae93ec57210ba761869c7d8ed9cf74 CHATGPT_HANDOFF_PROMPT.md a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUNBOOK.md adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -40f034a20cbcbb928fec310bbe56b27c73104ea48108b792677fc26de352be64 README.md -4a4b64b2de7997a23d3f36365c8afc05cef4462771a7a19bc653c4c705690abf pack.yaml -d4e8b40779a4293ad8f15face0b4cd3b37ec78b79f005b910bde644f329ec3be phase-0/MASTER_IMPLEMENTATION_PROMPT.md -d9c303351fd8f94fd9191f5aa97b704609af94f4ffb45ae188c8d041f4cbce5b phase-0/README.md -e27d2cd479f66651472ba2a942b8503eaef5c23c5c7986fb06afa8fb2dfe03e1 phase-0/task-manifest.yaml -ea63a1bf8f3ef5c4ca13d866001776c3cfb043d6ccb80ef9e0663b9246cd2ae8 phase-0/tickets/P0-00-baseline-and-preflight.md -e037b22bd3df3c487a7b557f9867dc8177b85a13086cd259e3ad394382aa758c phase-0/tickets/P0-01-primary-research-refresh.md -8e5345cbb70d1c11d253401bc095123c3bd97be032f936610d8048c87b819c31 phase-0/tickets/P0-02-architecture-review-and-plan.md -99c116e2a12cc001a74aa9af5586b17f051306a1e834e5ff3a1f9066a19e4b20 phase-1/MASTER_IMPLEMENTATION_PROMPT.md -076699a54b1193ca70e27b6be44db93ed458cbf063bdd29cb9bdafbe86feece9 phase-1/README.md -abe24390206ea3e9db2c27cb8c65435a3f29814b1b38454eff2daf99f71ed519 phase-1/task-manifest.yaml -a99ad2d2765aa6e2a26420ea2ffa335ee83fd75578037b315e8c3d2d766872c6 phase-1/tickets/P1-00-baseline-and-preflight.md -be3c6af65e201c5e3f4f20a731f63370cbcfd9db1b554ba4ce2e877a9f0c490d phase-1/tickets/P1-01-domain-services-and-contracts.md -6815d3ee60ef8c5a1777662ab9f4f5603ed06af1d6760a73b9319f06efa84f8b phase-1/tickets/P1-02-independent-phase-review.md -5d44b831ff83feac27855da08c0c3a2e5f9cb8711e7cc09ba0b7c37148f41ba4 phase-2/MASTER_IMPLEMENTATION_PROMPT.md -18fc626cecbcb371fb62a305a7617988788282a1458e31ef729adb58ab34f94e phase-2/README.md -7ad6aab7aee3305e0da60c06fd26b3bddb568ed05b01301c8458c55b240846d8 phase-2/task-manifest.yaml -aa4be85a853fb104aec89537c5d2da26bfb272ce408a9f37c85fca22a1c8754e phase-2/tickets/P2-00-baseline-and-preflight.md -fc188e6af28d74dda7d5264baada5d1445b71b74c800d6fc9c28205bbe47ff46 phase-2/tickets/P2-01-read-only-stdio-server.md -4a993bd750d1ecfeea89e376d0804be281b8549bef798f9b8826c6b66c5c893a phase-2/tickets/P2-02-security-conformance-review.md -9e66e5e37da8057c5640b15dcc644ce323b60b7f3178cfa3a9e85504d8913cfc phase-3/MASTER_IMPLEMENTATION_PROMPT.md -8cc14937de6d179f066ee90715f0f816f2d6bcfbe08684043fd31616f8ad721b phase-3/README.md -a27a9fc70ecc65ca8cbffb728bcb6da1690df49515b5fde28f98e6593bee63a6 phase-3/task-manifest.yaml -434147effe66cbae328a54182bf85625a25f0246be8fa3c75595eb0dca901894 phase-3/tickets/P3-00-workflow-baseline.md -902538b2ed4099776e9a8411f99a611cf5e864d2641ff40e3cdd96e10cc88d4b phase-3/tickets/P3-01-workflow-aware-tools.md -613d19df7075e45fc90c137ac0a52fa843e56d09ab381a46ee43a79507fd1795 phase-3/tickets/P3-02-independent-review.md -4346f5ab221a1a9eac21fdff24315c7c49d0a192b45f5afece8da8f48b591872 references/README.md -cc792c567a4900dd9f270ad5c7eec835d4b8bc6eed9353a31d093759476036c6 references/code-structure-outlines.md +f12e66a7da9b0f9b3d23ab0c7feccf1a45e20198f7b7f7c6a527fca86dc3594f README.md +a40ead589126162ee8d85249287e4c8950672f3abba28a7901223387f4813909 pack.yaml +ce3aa6e86c1a8b044c6400a8b6cf3d60843351d6da8bade1a9e850147dc96a08 phase-0/MASTER_IMPLEMENTATION_PROMPT.md +fa6a1bbe6159a85f58f8c5e350f54a858571523b7226ae38dfdda4c9179edd9d phase-0/README.md +19f9897f63f24ef7a967d583fcf2a9286ca99ec47e0aa05548a1c10985845ea1 phase-0/task-manifest.yaml +7178ca60aed3d7ab9f3e42f4bb7f82a5567ad547224d557fb856999f35cbc058 phase-0/tickets/MCP3-00-workflow-baseline.md +d3c16e5e2ed702a2c4c89271d6331a93b873afec068a23a366d8432beb90c971 phase-0/tickets/MCP3-01-workflow-aware-tools.md +2fa96703e1a52d9314b3c68337eea36bcce40a8c7c11729381a54548f4c6e4fd phase-0/tickets/MCP3-02-independent-review.md +8dda54259ebca6babf59b4033361d1544b1dab427c3124787b903472c82ecaa3 references/README.md +4a0559b3b242f49355c0b16c043f4271926f8991bdf7356c3655f570d66e2a34 references/code-structure-outlines.md 4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh 004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh 0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh diff --git a/prompt-packs/mcp-server-next/README.md b/prompt-packs/mcp-server-next/README.md index 4721bdf..cdc4b62 100644 --- a/prompt-packs/mcp-server-next/README.md +++ b/prompt-packs/mcp-server-next/README.md @@ -2,56 +2,26 @@ ## Purpose -Maintain the optional local-stdio MCP adapter without creating a second -orchestrator. Phases 0 through 2 cover the completed research, shared read -services, and bounded read-only server. Phase 3 is the remaining safe mutation -surface. The workflow foundation is complete through canonical backlog task -`WF-22`, so Phase 3 is now ready for a separately authorized execution. - -The workflow foundation is implemented first because MCP must wrap stable -workflow, routing, lifecycle, approval, and receipt services. It must not invent -parallel state machines or reinterpret workflow files itself. - -## Source baseline - -The companion source archive is a filtered snapshot of the `agent-workflow` -checkout prepared from the pre-0.2.0 implementation. Current extracted source, -release 0.2.1, remains authoritative when it differs from historical pack text. -The 0.2.1 hardening pass corrected workflow replay, durable child authority, -projection recovery, evidence validation, and concurrent sealing boundaries. - -## Phase map - -| Phase | Objective | State | Exit dependency | -|---|---|---|---| -| 0 | Research refresh, architecture review, and executable planning | completed | Accepted source baseline and primary-source evidence | -| 1 | Reusable domain seams and typed read contracts | completed | Phase 0 accepted | -| 2 | Local stdio read-only resource/server implementation and conformance | completed | Phase 1 accepted | -| 3 | Workflow-aware safe mutation adapter | ready | WF-22 and Phase 2 accepted; execute only as a separate MCP-003 scope | - -## Universal delegation rules - -- Execute every ticket in a fresh named terminal session. -- Use an isolated worktree unless the ticket is explicitly read-only. -- Read required references and current source before editing. -- Follow writable-path restrictions. -- Do not add tests without naming the contract or failure they protect. -- Stop when source contradicts the ticket in a way that could overwrite newer architecture. -- Produce a ticket completion report and preserve all command output. -- Use configured agent classes; implementation work is interactive unless policy says otherwise. -- Use `agent-workflow launch`, never raw tmux or direct executor spawning. -- MCP remains a client adapter. Workflow scheduling, child launch, routing policy, - approvals, messages, and receipts remain authoritative in shared services. -- Do not expose HTTP, force kill, arbitrary paths, raw terminal capture, shell - strings, environment dumps, direct state-file writes, or MCP Tasks as the - workflow authority. -- Runtime code may depend on `mcp==1.28.1`; do not import private APIs or copy - the SDK into runtime application code. - -## How to execute remaining work - -`WF-22` is done. Start -with `phase-3/tickets/P3-00-workflow-baseline.md`, which must prove every MCP -operation maps to a stable shared service. Validate with -`agent-workflow pack validate` and run focused MCP/workflow tests before any -mutation implementation. +Implement only the next authorized MCP mutation phase. The read-only local stdio adapter and shared read services already exist. This pack adds no new orchestrator and must wrap the same application services used by the CLI. + +## Scope + +The single phase covers: + +1. service and contract baseline verification; +2. bounded idempotent mutation tools; +3. independent security and parity review. + +The allowed tool candidates are pack validation, worktree creation, one bounded run launch, workflow validate/start/status/resume, and durable progress/ack/steer. A candidate must be omitted when its shared service, idempotency contract, or durable evidence mapping is not ready. + +## Non-targets + +No HTTP, MCP Tasks as lifecycle authority, arbitrary shell or paths, direct tmux controls, raw terminal capture, force kill, direct state-file mutation, alternate routing/scheduling, memory infrastructure, federation, or persona catalogs. + +## Execution + +Start with `phase-0/tickets/MCP3-00-workflow-baseline.md`. Read current source, `BACKLOG.md`, `docs/ARCHITECTURE.md`, `docs/MCP_SERVER.md`, `docs/OPERATIONS.md`, and `docs/TESTING.md`. Validate this pack before delegation: + +```bash +agent-workflow pack validate prompt-packs/mcp-server-next +``` diff --git a/prompt-packs/mcp-server-next/pack.yaml b/prompt-packs/mcp-server-next/pack.yaml index 1078750..46011e8 100644 --- a/prompt-packs/mcp-server-next/pack.yaml +++ b/prompt-packs/mcp-server-next/pack.yaml @@ -2,7 +2,7 @@ schema: agent-workflow/pack/v1 pack_id: "mcp-server-next" workflow: name: agent-workflow - minimum_version: "0.2.1" + minimum_version: "0.2.2" terminal_backend: tmux requires: - isolated_worktree diff --git a/prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md index 6fac05a..727890c 100644 --- a/prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +++ b/prompt-packs/mcp-server-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md @@ -1,32 +1,9 @@ -# Phase 0 Master Implementation Prompt +# MCP mutation phase master implementation prompt -## Role +Read the current workflow services, receipts, routing policy, MCP documentation, testing policy, and all phase tickets before editing. -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. +Implement the smallest useful local-stdio mutation surface. Every MCP tool must call an existing validated service used by the CLI or a transport-neutral service added for both CLI and MCP. MCP must never parse or mutate workflow/run state directly. -Coordinate baseline capture, current primary-source research, architecture -review, and an implementation-ready plan. Do not modify production source. +Limit tools to the validation, creation, launch, workflow, and durable messaging operations explicitly authorized by the tickets. Preserve idempotency, configured-root containment, stable errors, bounded inputs/outputs, policy enforcement, and immutable evidence. Steering remains pending without correlated executor acknowledgement. -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. - -The phase report must state which approved MCP decisions remain valid, which -need revision, why, and whether Phase 1 is safe to begin. +Extend installed-product MCP journeys first. Add a direct invariant only for a general security, replay, idempotency, or accounting boundary that cannot be exercised deterministically through the public protocol. diff --git a/prompt-packs/mcp-server-next/phase-0/README.md b/prompt-packs/mcp-server-next/phase-0/README.md index b03c3aa..0e566d2 100644 --- a/prompt-packs/mcp-server-next/phase-0/README.md +++ b/prompt-packs/mcp-server-next/phase-0/README.md @@ -1,28 +1,15 @@ -# Phase 0 — research, review, and executable plan +# MCP mutation phase -Refresh primary-source MCP evidence, independently review the approved decisions -against current source, and emit an implementation-ready plan and revised ticket -pack before production code changes. +## Objective -## Complexity and delegation +Add the smallest useful local-stdio mutation adapter over stable services shared with the CLI. -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P0-00 | C | Read-only | none | coordinator verifies baseline | -| P0-01 | B | Read-only research | P0-00 | source links and version claims checked | -| P0-02 | A | Architecture judgment | P0-01 | independent reviewer signs decision delta | +## Tickets -## Ordering +1. `MCP3-00` — verify service boundaries and refresh typed contracts. +2. `MCP3-01` — implement bounded idempotent tools. +3. `MCP3-02` — independently review parity, security, restart behavior, and evidence. -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. +## Non-targets -No production writes. Research uses primary sources. Do not silently reopen -approved decisions; record any challenge as a decision delta with evidence. - -Root README, `BACKLOG.md`, `docs/MCP_SERVER_DECISION.md`, MCP source/tests, -official SDK dependency record, and `references/`. - -Evidence contains the exact source baseline, research matrix, threat review, -decision deltas, dependency graph, bounded tickets, and revised prompt pack. -`agent-workflow pack validate` passes and an independent review finds no -unbounded ticket or unsupported protocol claim. +No arbitrary workflow execution, raw shell or tmux control, direct state-file mutation, bulk cross-run operations, force kill, HTTP transport, or MCP Tasks as lifecycle authority. diff --git a/prompt-packs/mcp-server-next/phase-0/task-manifest.yaml b/prompt-packs/mcp-server-next/phase-0/task-manifest.yaml index 261d7c8..5ef71c0 100644 --- a/prompt-packs/mcp-server-next/phase-0/task-manifest.yaml +++ b/prompt-packs/mcp-server-next/phase-0/task-manifest.yaml @@ -1,18 +1,18 @@ phase: "0" -name: "research-review-plan" -mandatory_order: ["P0-00", "P0-01", "P0-02"] +name: "workflow-aware-safe-mutation" +mandatory_order: ["MCP3-00", "MCP3-01", "MCP3-02"] tasks: - - id: "P0-00" - tier: C - session: "mcp-server-next-p0-00-baseline" - prompt: "tickets/P0-00-baseline-and-preflight.md" - - id: "P0-01" + - id: "MCP3-00" tier: B - session: "mcp-server-next-p0-01-research" - prompt: "tickets/P0-01-primary-research-refresh.md" - dependencies: ["P0-00"] - - id: "P0-02" + session: "mcp-server-next-baseline" + prompt: "tickets/MCP3-00-workflow-baseline.md" + - id: "MCP3-01" tier: A - session: "mcp-server-next-p0-02-review-plan" - prompt: "tickets/P0-02-architecture-review-and-plan.md" - dependencies: ["P0-01"] + session: "mcp-server-next-safe-mutation" + prompt: "tickets/MCP3-01-workflow-aware-tools.md" + dependencies: ["MCP3-00"] + - id: "MCP3-02" + tier: A + session: "mcp-server-next-review" + prompt: "tickets/MCP3-02-independent-review.md" + dependencies: ["MCP3-01"] diff --git a/prompt-packs/mcp-server-next/phase-3/tickets/P3-00-workflow-baseline.md b/prompt-packs/mcp-server-next/phase-0/tickets/MCP3-00-workflow-baseline.md similarity index 71% rename from prompt-packs/mcp-server-next/phase-3/tickets/P3-00-workflow-baseline.md rename to prompt-packs/mcp-server-next/phase-0/tickets/MCP3-00-workflow-baseline.md index db48ade..5122ab4 100644 --- a/prompt-packs/mcp-server-next/phase-3/tickets/P3-00-workflow-baseline.md +++ b/prompt-packs/mcp-server-next/phase-0/tickets/MCP3-00-workflow-baseline.md @@ -1,17 +1,17 @@ -# P3-00 — workflow baseline and MCP contract refresh +# MCP3-00 — workflow baseline and MCP contract refresh ## Goal -Prove that `WF-22` is complete and identify the exact stable services and typed -contracts MCP can reuse for workflow validation, launch, status, resume, routing +Identify the exact stable services and typed contracts MCP can reuse for workflow validation, launch, status, resume, routing explanation, approvals, and aggregate receipt inspection. ## Writable paths -- `docs/MCP_SERVER_DECISION.md` -- `docs/MCP_SERVER_IMPLEMENTATION_REPORT.md` +- `docs/MCP_SERVER.md` - `src/agent_workflow/mcp/**` -- `tests/test_mcp_*.py` +- `tests/acceptance/**` +- `tests/invariants/**` +- `tests/future/**` - this prompt pack Do not change workflow semantics in this ticket. @@ -27,10 +27,9 @@ Do not change workflow semantics in this ticket. ## Acceptance evidence -- `WF-22` completion evidence is cited. - Every retained tool has one authoritative service mapping. - No tool maps to tmux, shell strings, raw files, or mutable receipt internals. -- Focused contract tests pass. +- Installed-product parity journeys and any required security/idempotency matrices pass. ## Stop conditions diff --git a/prompt-packs/mcp-server-next/phase-3/tickets/P3-01-workflow-aware-tools.md b/prompt-packs/mcp-server-next/phase-0/tickets/MCP3-01-workflow-aware-tools.md similarity index 90% rename from prompt-packs/mcp-server-next/phase-3/tickets/P3-01-workflow-aware-tools.md rename to prompt-packs/mcp-server-next/phase-0/tickets/MCP3-01-workflow-aware-tools.md index 0cea975..cf277a3 100644 --- a/prompt-packs/mcp-server-next/phase-3/tickets/P3-01-workflow-aware-tools.md +++ b/prompt-packs/mcp-server-next/phase-0/tickets/MCP3-01-workflow-aware-tools.md @@ -1,4 +1,4 @@ -# P3-01 — workflow-aware safe mutation tools +# MCP3-01 — workflow-aware safe mutation tools ## Goal @@ -13,7 +13,7 @@ services shared with the CLI. - workflow `validate`, `launch`, `status`, and `resume` - `progress`, `ack`, and `steer` -A tool may be omitted when P3-00 proves its service contract is not ready. +A tool may be omitted when MCP3-00 proves its service contract is not ready. ## Safety and evidence requirements @@ -33,7 +33,7 @@ A tool may be omitted when P3-00 proves its service contract is not ready. - `src/agent_workflow/mcp/**` - shared service modules only when both CLI and MCP use the seam -- focused MCP/service tests +- installed-product MCP journeys and narrowly scoped invariant matrices - command/reference documentation and release manifest ## Acceptance evidence diff --git a/prompt-packs/mcp-server-next/phase-3/tickets/P3-02-independent-review.md b/prompt-packs/mcp-server-next/phase-0/tickets/MCP3-02-independent-review.md similarity index 75% rename from prompt-packs/mcp-server-next/phase-3/tickets/P3-02-independent-review.md rename to prompt-packs/mcp-server-next/phase-0/tickets/MCP3-02-independent-review.md index 64624a9..a10b898 100644 --- a/prompt-packs/mcp-server-next/phase-3/tickets/P3-02-independent-review.md +++ b/prompt-packs/mcp-server-next/phase-0/tickets/MCP3-02-independent-review.md @@ -1,9 +1,8 @@ -# P3-02 — independent workflow-aware MCP review +# MCP3-02 — independent workflow-aware MCP review ## Goal -Independently verify that Phase 3 is a thin adapter and did not create a second -workflow, routing, lifecycle, or evidence authority. +Independently verify that the MCP mutation phase is a thin adapter and did not create a second workflow, routing, lifecycle, or evidence authority. ## Review requirements @@ -14,8 +13,7 @@ workflow, routing, lifecycle, or evidence authority. bounded pagination/output, cancellation, and stable error mapping. - Run official Inspector/conformance tooling when available and record skips honestly when dependencies are unavailable. -- Run focused tests, full test modules, prompt-pack validation, schema checks, - and release-asset audit. +- Run installed-product MCP journeys, required invariant matrices, prompt-pack validation, schema checks, and the release-asset audit. ## Writable paths @@ -35,6 +33,6 @@ security result, skipped external check, and remaining deferred capability. ## Stop conditions -Stop and reject Phase 3 if any tool bypasses shared services, weakens configured-root +Stop and reject the MCP mutation phase if any tool bypasses shared services, weakens configured-root or policy enforcement, overstates steering delivery, or cannot produce durable evidence. Do not broaden scope to resolve unrelated failures. diff --git a/prompt-packs/mcp-server-next/phase-0/tickets/P0-00-baseline-and-preflight.md b/prompt-packs/mcp-server-next/phase-0/tickets/P0-00-baseline-and-preflight.md deleted file mode 100644 index cd01bf8..0000000 --- a/prompt-packs/mcp-server-next/phase-0/tickets/P0-00-baseline-and-preflight.md +++ /dev/null @@ -1,53 +0,0 @@ -# P0-00 — Baseline and preflight - -## Delegation metadata - -- Ticket: `P0-00` -- Recommended tier: C -- Dependencies: none -- New terminal session required: yes -- Implementation risk: read-only - -## Objective - -Capture the exact current source baseline and identify material drift from the pack references before implementation begins. - -## Required reading - -- repository root README and package metadata; -- this phase README and master prompt; -- `EXECUTION_PROTOCOL.md`; -- relevant included references. -- `CHATGPT_HANDOFF_PROMPT.md` and `docs/MCP_SERVER_DECISION.md`. - -## Writable paths - -Only the phase completion/evidence directory designated by the operator. Do not modify production source. - -## Procedure - -1. Record working directory, repository root, branch, revision, and dirty state. -2. Confirm every later ticket path exists or locate its current equivalent. -3. Compare current source with the pack's reviewed baseline. -4. Record contradictions that could invalidate later tickets. -5. Do not solve implementation tickets during this preflight. - -## Acceptance criteria - -- exact revisions and branches are recorded; -- dirty state is recorded; -- path mappings are explicit; -- blocking source contradictions are visible; -- no production source changed. - -## Necessary tests - -No new tests. Validate evidence with Git and filesystem commands only. - -## Stop and escalate conditions - -Stop if the repository or required references cannot be located, the worktree contains unexplained changes, or a later ticket would overwrite clearly newer architecture. - -## Required completion report - -Use `templates/TICKET_COMPLETION.md` and mark this ticket as read-only. diff --git a/prompt-packs/mcp-server-next/phase-0/tickets/P0-01-primary-research-refresh.md b/prompt-packs/mcp-server-next/phase-0/tickets/P0-01-primary-research-refresh.md deleted file mode 100644 index 0bbf5c5..0000000 --- a/prompt-packs/mcp-server-next/phase-0/tickets/P0-01-primary-research-refresh.md +++ /dev/null @@ -1,37 +0,0 @@ -# P0-01 — Primary-source MCP research refresh - -## Delegation metadata - -- Recommended class: `exploratory` -- Dependencies: P0-00 -- Risk: read-only research - -## Objective - -Verify the protocol, SDK, conformance, host, and security assumptions in the -approved decision against current official sources. - -## Writable paths - -Only operator-designated phase evidence and a proposed replacement research -reference inside this prompt pack. No production source. - -## Procedure and acceptance - -Research MCP specification 2025-11-25, official Python SDK stable 1.x pinned at -1.28.1, Inspector/conformance guidance, stdio lifecycle, cancellation/progress, -resource URI templates, tool annotations, and relevant official host behavior. -Record URL, publisher, access date, stable version/commit, direct implication, -and disagreement with current docs. Use primary sources only. Explicitly compare -stable 1.x with the v2 pre-release line and retain 1.28.1 unless evidence requires -a decision gate. Produce a source matrix and bounded recommendations. - -## Necessary tests - -No production tests. Verify every material claim against the linked primary -source and independently check cited version/tag/commit identifiers. - -## Stop conditions - -Stop before implementation. Escalate if the stable SDK or selected protocol is -unsupported, unavailable, or incompatible with Python 3.11. diff --git a/prompt-packs/mcp-server-next/phase-0/tickets/P0-02-architecture-review-and-plan.md b/prompt-packs/mcp-server-next/phase-0/tickets/P0-02-architecture-review-and-plan.md deleted file mode 100644 index 54e5b6b..0000000 --- a/prompt-packs/mcp-server-next/phase-0/tickets/P0-02-architecture-review-and-plan.md +++ /dev/null @@ -1,27 +0,0 @@ -# P0-02 — Independent architecture review and executable plan - -## Delegation metadata - -- Recommended class: `review` -- Dependencies: P0-01 -- Risk: architecture and security judgment - -## Objective - -Independently review the MCP decision and current scaffold, then produce a -dependency-ordered implementation plan, bounded tickets, and revised prompt pack. - -## Required checks - -Trace each proposed MCP operation to the authoritative Python service and durable -artifact. Threat-model traversal, symlink escape, secret leakage, confused deputy, -cross-run access, replay, false delivery claims, and denial of service. Confirm -that stdio remains local, MCP remains an adapter, and HTTP/destructive surfaces -remain excluded. Identify CLI parsing mixed with domain behavior. - -## Deliverables and acceptance - -Provide a decision-delta table, module/API outline, error taxonomy, compatibility -plan, test matrix, dependency graph, individually executable tickets, model/class -routing, exact writable paths, stop conditions, and independent phase gates. -Validate the revised pack. No implementation source may change. diff --git a/prompt-packs/mcp-server-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/mcp-server-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index b3a0714..0000000 --- a/prompt-packs/mcp-server-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,32 +0,0 @@ -# Phase 1 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. - -Coordinate a narrow MCP-0 seam extraction. Preserve lifecycle semantics and keep -MCP/CLI adapters thin over shared typed services. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. - -Do not advance unless the independent reviewer accepts root containment, -redaction, stable errors, compatibility, and shared-service evidence. diff --git a/prompt-packs/mcp-server-next/phase-1/README.md b/prompt-packs/mcp-server-next/phase-1/README.md deleted file mode 100644 index de6e09b..0000000 --- a/prompt-packs/mcp-server-next/phase-1/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Phase 1 — MCP-0 domain seams and typed contracts - -Extract the smallest reusable, transport-neutral MCP domain surface and prove the -CLI/MCP adapters share it without changing lifecycle semantics. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P1-00 | B | Read-only seam audit | Phase 0 | coordinator review | -| P1-01 | A | Cross-module implementation | P1-00 | focused tests | -| P1-02 | A | Independent phase gate | P1-01 | separate reviewer | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -No HTTP, tmux exposure, destructive tools, arbitrary filesystem APIs, or broad -CLI rewrite. Preserve schemas and durable evidence semantics. - -Phase 0 outputs, MCP decision, code structure outline, current state/messages/ -receipts/pack services, MCP scaffold, tests, and schemas. - -Focused contract/service tests, existing CLI tests, full pytest, schema checks, -release audit, and independent diff review all pass. diff --git a/prompt-packs/mcp-server-next/phase-1/task-manifest.yaml b/prompt-packs/mcp-server-next/phase-1/task-manifest.yaml deleted file mode 100644 index cd1e4db..0000000 --- a/prompt-packs/mcp-server-next/phase-1/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: "1" -name: "domain-seams-contracts" -mandatory_order: ["P1-00", "P1-01", "P1-02"] -tasks: - - id: "P1-00" - tier: C - session: "mcp-server-next-p1-00-baseline" - prompt: "tickets/P1-00-baseline-and-preflight.md" - dependencies: ["P0-02"] - - id: "P1-01" - tier: A - session: "mcp-server-next-p1-01-domain-services" - prompt: "tickets/P1-01-domain-services-and-contracts.md" - dependencies: ["P1-00"] - - id: "P1-02" - tier: A - session: "mcp-server-next-p1-02-review" - prompt: "tickets/P1-02-independent-phase-review.md" - dependencies: ["P1-01"] diff --git a/prompt-packs/mcp-server-next/phase-1/tickets/P1-00-baseline-and-preflight.md b/prompt-packs/mcp-server-next/phase-1/tickets/P1-00-baseline-and-preflight.md deleted file mode 100644 index 1bf4d7f..0000000 --- a/prompt-packs/mcp-server-next/phase-1/tickets/P1-00-baseline-and-preflight.md +++ /dev/null @@ -1,56 +0,0 @@ -# P1-00 — Baseline and preflight - -## Delegation metadata - -- Ticket: `P1-00` -- Recommended tier: C -- Dependencies: none -- New terminal session required: yes -- Implementation risk: read-only - -## Objective - -Capture the exact current source baseline and identify material drift from the pack references before implementation begins. - -Additionally inventory every CLI handler and existing MCP function relevant to -run listing, status, messages, receipts, and pack validation; propose the minimum -shared service boundary without editing production source. - -## Required reading - -- repository root README and package metadata; -- this phase README and master prompt; -- `EXECUTION_PROTOCOL.md`; -- relevant included references. - -## Writable paths - -Only the phase completion/evidence directory designated by the operator. Do not modify production source. - -## Procedure - -1. Record working directory, repository root, branch, revision, and dirty state. -2. Confirm every later ticket path exists or locate its current equivalent. -3. Compare current source with the pack's reviewed baseline. -4. Record contradictions that could invalidate later tickets. -5. Do not solve implementation tickets during this preflight. - -## Acceptance criteria - -- exact revisions and branches are recorded; -- dirty state is recorded; -- path mappings are explicit; -- blocking source contradictions are visible; -- no production source changed. - -## Necessary tests - -No new tests. Validate evidence with Git and filesystem commands only. - -## Stop and escalate conditions - -Stop if the repository or required references cannot be located, the worktree contains unexplained changes, or a later ticket would overwrite clearly newer architecture. - -## Required completion report - -Use `templates/TICKET_COMPLETION.md` and mark this ticket as read-only. diff --git a/prompt-packs/mcp-server-next/phase-1/tickets/P1-01-domain-services-and-contracts.md b/prompt-packs/mcp-server-next/phase-1/tickets/P1-01-domain-services-and-contracts.md deleted file mode 100644 index 12790d9..0000000 --- a/prompt-packs/mcp-server-next/phase-1/tickets/P1-01-domain-services-and-contracts.md +++ /dev/null @@ -1,32 +0,0 @@ -# P1-01 — Domain services and typed MCP contracts - -## Delegation metadata - -- Recommended class: `implementation` -- Dependencies: P1-00 and accepted Phase 0 -- Writable paths: `src/agent_workflow/mcp/`, narrowly required shared domain - modules, focused tests, schemas/docs directly changed by the contract - -## Objective - -Implement immutable typed requests/results and transport-neutral services for -bounded run listing, status, messages, receipt metadata, and pack validation. - -## Acceptance criteria - -Services validate identifiers, enforce configured-root containment after realpath -resolution, reject symlink escapes, paginate with hard bounds, redact prohibited -fields, and return stable error categories. MCP decorators and CLI formatting do -not contain domain policy. At least one CLI/MCP operation is proven by tests to -call the same service boundary. Existing lifecycle output remains compatible. - -## Necessary tests - -Focused success/failure tests for invalid IDs, missing runs, forbidden roots, -symlink escapes, pagination bounds, redaction, receipt integrity, and shared -adapter invocation; then full pytest and release audit. - -## Stop conditions - -Stop if reuse requires lifecycle semantic changes, schema migration, destructive -actions, or a broad state-store rewrite. Return a bounded refactor proposal. diff --git a/prompt-packs/mcp-server-next/phase-1/tickets/P1-02-independent-phase-review.md b/prompt-packs/mcp-server-next/phase-1/tickets/P1-02-independent-phase-review.md deleted file mode 100644 index 55a3d75..0000000 --- a/prompt-packs/mcp-server-next/phase-1/tickets/P1-02-independent-phase-review.md +++ /dev/null @@ -1,18 +0,0 @@ -# P1-02 — Independent MCP-0 phase review - -## Scope and writable paths - -Use class `review`. Only the operator-designated review report is writable; do -not edit production source. - -## Procedure and tests - -Inspect the diff and trace every public service to its authority. Rerun focused -service/adapter tests, full pytest, schema validation, and release audit. - -## Acceptance and stop conditions - -Reject and stop on path escape, secret leakage, duplicated lifecycle logic, -private SDK imports, behavior drift, weak errors, missing bounds, or implementation -outside Phase 1. Produce a signed accept/reject report with exact commands and -revision. diff --git a/prompt-packs/mcp-server-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/mcp-server-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 6b52b48..0000000 --- a/prompt-packs/mcp-server-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,32 +0,0 @@ -# Phase 2 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. - -Coordinate completion of the optional read-only local stdio MCP server using -public stable SDK APIs and accepted Phase 1 services. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. - -Do not advance to mutation or HTTP. Require independent security/conformance -acceptance and preserve tool versions and command evidence. diff --git a/prompt-packs/mcp-server-next/phase-2/README.md b/prompt-packs/mcp-server-next/phase-2/README.md deleted file mode 100644 index db02435..0000000 --- a/prompt-packs/mcp-server-next/phase-2/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Phase 2 — MCP-1 read-only local stdio server - -Complete and harden the optional local stdio MCP server over accepted Phase 1 -services using public APIs from `mcp==1.28.1`. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P2-00 | B | Scaffold/conformance audit | Phase 1 | coordinator review | -| P2-01 | A | Server implementation | P2-00 | protocol tests | -| P2-02 | A | Security/conformance gate | P2-01 | independent reviewer | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -Stdio only. Read-only resources plus `pack_validate`; no lifecycle mutation, -raw terminal capture, HTTP, arbitrary paths, or private SDK APIs. - -Accepted Phase 1 report, official stable SDK/spec research, MCP decision, current -server/tests, package metadata, installer, and release audit. - -Import without MCP extra fails with a concise optional-dependency message; import -with the extra works. Focused resources, traversal/redaction, stdio smoke, -Inspector/conformance where available, full pytest, build, and release audit pass. diff --git a/prompt-packs/mcp-server-next/phase-2/task-manifest.yaml b/prompt-packs/mcp-server-next/phase-2/task-manifest.yaml deleted file mode 100644 index e51724f..0000000 --- a/prompt-packs/mcp-server-next/phase-2/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: "2" -name: "read-only-stdio-server" -mandatory_order: ["P2-00", "P2-01", "P2-02"] -tasks: - - id: "P2-00" - tier: C - session: "mcp-server-next-p2-00-baseline" - prompt: "tickets/P2-00-baseline-and-preflight.md" - dependencies: ["P1-02"] - - id: "P2-01" - tier: A - session: "mcp-server-next-p2-01-stdio-server" - prompt: "tickets/P2-01-read-only-stdio-server.md" - dependencies: ["P2-00"] - - id: "P2-02" - tier: A - session: "mcp-server-next-p2-02-security-review" - prompt: "tickets/P2-02-security-conformance-review.md" - dependencies: ["P2-01"] diff --git a/prompt-packs/mcp-server-next/phase-2/tickets/P2-00-baseline-and-preflight.md b/prompt-packs/mcp-server-next/phase-2/tickets/P2-00-baseline-and-preflight.md deleted file mode 100644 index 5902b29..0000000 --- a/prompt-packs/mcp-server-next/phase-2/tickets/P2-00-baseline-and-preflight.md +++ /dev/null @@ -1,56 +0,0 @@ -# P2-00 — Baseline and preflight - -## Delegation metadata - -- Ticket: `P2-00` -- Recommended tier: C -- Dependencies: none -- New terminal session required: yes -- Implementation risk: read-only - -## Objective - -Capture the exact current source baseline and identify material drift from the pack references before implementation begins. - -Additionally audit the current MCP scaffold against accepted Phase 1 services, -the pinned SDK public API, package extras/entry point, and available conformance -tools. Record gaps before implementation. - -## Required reading - -- repository root README and package metadata; -- this phase README and master prompt; -- `EXECUTION_PROTOCOL.md`; -- relevant included references. - -## Writable paths - -Only the phase completion/evidence directory designated by the operator. Do not modify production source. - -## Procedure - -1. Record working directory, repository root, branch, revision, and dirty state. -2. Confirm every later ticket path exists or locate its current equivalent. -3. Compare current source with the pack's reviewed baseline. -4. Record contradictions that could invalidate later tickets. -5. Do not solve implementation tickets during this preflight. - -## Acceptance criteria - -- exact revisions and branches are recorded; -- dirty state is recorded; -- path mappings are explicit; -- blocking source contradictions are visible; -- no production source changed. - -## Necessary tests - -No new tests. Validate evidence with Git and filesystem commands only. - -## Stop and escalate conditions - -Stop if the repository or required references cannot be located, the worktree contains unexplained changes, or a later ticket would overwrite clearly newer architecture. - -## Required completion report - -Use `templates/TICKET_COMPLETION.md` and mark this ticket as read-only. diff --git a/prompt-packs/mcp-server-next/phase-2/tickets/P2-01-read-only-stdio-server.md b/prompt-packs/mcp-server-next/phase-2/tickets/P2-01-read-only-stdio-server.md deleted file mode 100644 index 1cdb09a..0000000 --- a/prompt-packs/mcp-server-next/phase-2/tickets/P2-01-read-only-stdio-server.md +++ /dev/null @@ -1,28 +0,0 @@ -# P2-01 — Read-only local stdio MCP server - -## Delegation metadata - -- Recommended class: `implementation` -- Dependencies: accepted Phase 1 and P2-00 -- Writable paths: `src/agent_workflow/mcp/`, MCP-focused tests, narrowly required - package/installer/docs/schema files - -## Objective and contract - -Register bounded run list/status/messages/receipt and pack resources plus the -read-only `pack_validate` tool with FastMCP public APIs. Run only over stdio. Use -actor identity `mcp-stdio:`, declare capabilities accurately, -and map service errors without leaking filesystem or environment secrets. - -## Tests and acceptance - -Test registration, typed success/errors, URI and ID validation, pagination hard -limits, traversal/symlink denial, redaction, optional dependency absence, public -SDK imports, stdio initialization and one resource/tool request. Run official -Inspector/conformance tooling if supported locally and preserve versions/output. -Then run full pytest, build, and release audit. - -## Stop conditions - -Stop before adding mutation, HTTP/SSE, OAuth, raw capture, direct tmux, arbitrary -shell/path input, private SDK APIs, or MCP Tasks as lifecycle authority. diff --git a/prompt-packs/mcp-server-next/phase-2/tickets/P2-02-security-conformance-review.md b/prompt-packs/mcp-server-next/phase-2/tickets/P2-02-security-conformance-review.md deleted file mode 100644 index 1f12228..0000000 --- a/prompt-packs/mcp-server-next/phase-2/tickets/P2-02-security-conformance-review.md +++ /dev/null @@ -1,20 +0,0 @@ -# P2-02 — Independent security and conformance review - -## Scope and writable paths - -Use class `review`; only the operator-designated review report is writable and -production source is read-only. - -## Procedure and tests - -Independently test malformed identifiers, URI templates, oversized limits, -traversal and symlink escapes, cross-root access, redaction, missing/tampered -receipts, missing optional SDK, cancellation, and clean stdio shutdown. Rerun -focused MCP tests, official conformance tooling where available, full pytest, -build, and release audit. - -## Acceptance and stop conditions - -Inspect imports and reject/stop if any private SDK API, HTTP listener, shell, -tmux, environment dump, raw capture, or destructive tool is exposed. Issue an -accept/reject report with exact revisions, commands, evidence, and residual risks. diff --git a/prompt-packs/mcp-server-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/mcp-server-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 366e41a..0000000 --- a/prompt-packs/mcp-server-next/phase-3/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,15 +0,0 @@ -# Phase 3 master implementation prompt - -Execute Phase 3 only after canonical backlog task `WF-22` is complete. Read the -current workflow service, receipts, routing policy, MCP decision, implementation -report, and all Phase 3 tickets before editing. - -Implement the smallest useful mutation surface. Every MCP tool must call an -existing validated service used by the CLI or a transport-neutral service added -for both CLI and MCP. MCP must never parse or mutate workflow/run state directly. - -Required tools are limited to safe validation, creation, launch, status/resume, -and durable messaging controls explicitly authorized by the tickets. Preserve -idempotency, configured-root containment, stable errors, bounded inputs/outputs, -and immutable evidence. Steering remains pending without correlated executor -acknowledgement. diff --git a/prompt-packs/mcp-server-next/phase-3/README.md b/prompt-packs/mcp-server-next/phase-3/README.md deleted file mode 100644 index 34dc264..0000000 --- a/prompt-packs/mcp-server-next/phase-3/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Phase 3 — workflow-aware safe mutation adapter - -## Objective - -Add the remaining local-stdio mutation tools only after `workflow-foundations-next` -finishes through `WF-22`. MCP remains a thin adapter over stable CLI/domain -services; it does not own scheduling, workflow state, tmux, worktrees, routing, -or receipts. - -## Required external prerequisite - -- Canonical backlog task `WF-22` is complete. -- Workflow validate/launch/status/resume services and aggregate receipts exist. -- Existing single-run launch/control services remain authoritative. - -## Tickets - -1. `P3-00` — verify the workflow foundation and refresh MCP contracts. -2. `P3-01` — implement bounded workflow-aware and single-run mutation tools. -3. `P3-02` — independently review security, idempotency, evidence, and host behavior. - -## Non-targets - -No arbitrary workflow definition execution, raw shell or tmux control, direct -state-file mutation, bulk cross-run operations, force kill, HTTP transport, -MCP Tasks as lifecycle authority, or duplicated routing/scheduling logic. diff --git a/prompt-packs/mcp-server-next/phase-3/task-manifest.yaml b/prompt-packs/mcp-server-next/phase-3/task-manifest.yaml deleted file mode 100644 index 73d4d26..0000000 --- a/prompt-packs/mcp-server-next/phase-3/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: "3" -name: "workflow-aware-safe-mutation" -mandatory_order: ["P3-00", "P3-01", "P3-02"] -tasks: - - id: "P3-00" - tier: B - session: "mcp-server-next-p3-00-workflow-baseline" - prompt: "tickets/P3-00-workflow-baseline.md" - dependencies: ["P2-02"] - - id: "P3-01" - tier: A - session: "mcp-server-next-p3-01-safe-mutation" - prompt: "tickets/P3-01-workflow-aware-tools.md" - dependencies: ["P3-00"] - - id: "P3-02" - tier: A - session: "mcp-server-next-p3-02-review" - prompt: "tickets/P3-02-independent-review.md" - dependencies: ["P3-01"] diff --git a/prompt-packs/mcp-server-next/references/README.md b/prompt-packs/mcp-server-next/references/README.md index d02560f..4b1b582 100644 --- a/prompt-packs/mcp-server-next/references/README.md +++ b/prompt-packs/mcp-server-next/references/README.md @@ -1,18 +1,11 @@ # References -Required source references in the companion archive: +Use current source and these canonical documents: -- `docs/MCP_SERVER_DECISION.md` — approved decisions and phased capability map; -- `BACKLOG.md` — canonical MCP and orchestration task states; -- `src/agent_workflow/mcp/` and `tests/test_mcp_server.py` — current scaffold; -- `src/agent_workflow/{cli,config,state,messages,receipts,sessions}.py` — lifecycle - and durable-record authorities; -- `schemas/`, `scripts/audit-release-assets.py`, and `MANIFEST.sha256` — release - and wire-format gates; -- `src/agent_workflow/mcp/SDK_DEPENDENCY.md` — pinned SDK dependency and research provenance; -- `docs/GLOBAL_AGENT_ROUTING.md` and `skills/agent-workflow-orchestrator/` — - routing boundary and operational use. - -Refresh web research from primary sources listed in the MCP decision. Record -exact stable versions and access dates. References constrain implementation; -current checked-out source remains authoritative. +- `BACKLOG.md` — authorization and exit evidence; +- `docs/ARCHITECTURE.md` — shared service and authority model; +- `docs/MCP_SERVER.md` — current and planned MCP boundary; +- `docs/OPERATIONS.md` — lifecycle, routing, and steering semantics; +- `docs/TESTING.md` — acceptance-first testing rules; +- `SECURITY.md` — repository trust guidance; +- official MCP protocol and SDK documentation for the pinned dependency. diff --git a/prompt-packs/mcp-server-next/references/code-structure-outlines.md b/prompt-packs/mcp-server-next/references/code-structure-outlines.md index 6943310..d5d48d1 100644 --- a/prompt-packs/mcp-server-next/references/code-structure-outlines.md +++ b/prompt-packs/mcp-server-next/references/code-structure-outlines.md @@ -23,7 +23,7 @@ For each proposed module or interface, include: text. - `src/agent_workflow/cli.py`: preserve behavior; where practical, call the same service function as MCP and test the shared call boundary. -- `tests/test_mcp_server.py` plus focused service tests: missing/invalid IDs, +- `tests/acceptance/` installed-product MCP journeys plus focused invariant matrices: missing/invalid IDs, symlink escape, configured-root authorization, pagination bounds, redaction, receipt hashes, SDK absence, import safety, and stdio smoke/conformance. diff --git a/prompt-packs/orchestrator-messaging-evals/DELEGATION_RUNBOOK.md b/prompt-packs/orchestrator-messaging-evals/DELEGATION_RUNBOOK.md deleted file mode 100644 index a2fa88a..0000000 --- a/prompt-packs/orchestrator-messaging-evals/DELEGATION_RUNBOOK.md +++ /dev/null @@ -1,61 +0,0 @@ -# Delegation Runbook - -## Preflight - -```bash -agent-workflow doctor -agent-workflow config show -agent-workflow pack validate /path/to/prompt-pack -agent-workflow worktree create /path/to/repository P0-01 HEAD -``` - -## Launch in a fresh terminal - -```bash -agent-workflow launch project-p0-01-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-01.md --ticket P0-01 --pack project-phases-0-2 --executor codex -agent-workflow launch project-p0-02-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-02.md --ticket P0-02 --pack project-phases-0-2 --executor claude -``` - -The prompt is passed to the command over standard input. - -## Workflow skills - -| Purpose | Codex | Claude | -|---|---|---| -| Build a prompt pack | `$prompt-pack-builder` | `/prompt-pack-builder` | -| Implement one ticket | `$delegated-implementation` | `/delegated-implementation` | -| Review a completed phase | `$phase-gate-review` | `/phase-gate-review` | - -## Observe and foreground - -```bash -agent-workflow list -agent-workflow status project-p0-01 --capture 60 -agent-workflow attach project-p0-01 -agent-workflow tail project-p0-01 -``` - -`possibly_stalled` is advisory. It means tmux is alive while the log has not grown during the configured threshold. - -## Stall handling - -1. Run `status --capture 100`. -2. Attach to the session. -3. Classify input wait, package/network wait, test deadlock, model loop, or legitimate long operation. -4. Interrupt without deleting evidence. -5. Correct the prompt or environment. -6. Restart into a new `-retryN` session. - -## Stop controls - -```bash -agent-workflow interrupt SESSION -agent-workflow terminate SESSION --grace-seconds 8 -agent-workflow kill SESSION -``` - -Use immediate kill only for an unresponsive process. All controls preserve durable evidence. - -## Completion and review - -Require a ticket completion report, inspect the diff before running tests, independently rerun narrow acceptance commands, and create a phase-gate report. A high-risk implementer should not be the only reviewer. diff --git a/prompt-packs/orchestrator-messaging-evals/EXECUTION_PROTOCOL.md b/prompt-packs/orchestrator-messaging-evals/EXECUTION_PROTOCOL.md deleted file mode 100644 index 132cc39..0000000 --- a/prompt-packs/orchestrator-messaging-evals/EXECUTION_PROTOCOL.md +++ /dev/null @@ -1,73 +0,0 @@ -# Execution Protocol - -## 1. Source-of-truth hierarchy - -Use this order when sources disagree: - -1. current checked-out source and argument parsers; -2. current schemas, migrations, and package metadata; -3. tests that exercise current runtime behavior; -4. verified review findings and source excerpts; -5. current README, man page, and skill claims; -6. historical plans and progress notes. - -Never implement a historical “completed” claim without confirming that the behavior exists on the checked-out revision. - -## 2. Required preflight for every ticket - -Record: - -```bash -pwd -git status --short -git rev-parse --show-toplevel -git rev-parse HEAD -git branch --show-current -python3 --version -``` - -For a multi-repository workspace, record the same data for every repository touched. Inspect every path named by the ticket before editing it. If a path has moved, locate the current equivalent and record the mapping; do not recreate removed files merely to match an old prompt. - -## 3. Drift handling - -- If the source matches the reviewed shape, implement the ticket. -- If the source already contains a correct implementation, verify it and limit work to missing acceptance gates. -- If the source partially changed, adapt narrowly and document the delta. -- If the ticket would overwrite newer architecture, schema, or migration work, stop and escalate. -- Never broaden writable paths without explicit authorization. - -## 4. New-terminal and observability rule - -Every delegation runs in a fresh named `tmux` session. The session name includes project/pack, phase, and ticket identity. The session must be foregroundable and must write a live persistent log. - -A delegation is only **potentially** stalled when its terminal is alive and the live log has not changed for the configured interval. Foreground and inspect before interrupting it. Never automatically kill a session merely because a timer elapsed. - -## 5. Implementation discipline - -- Read before editing. -- Make the smallest coherent change. -- Prefer removing a contradictory authorized surface over adding compatibility indirection. -- Do not add a framework, service, database, UI, worker, or build system unless the ticket requires it. -- Do not rename public interfaces outside ticket scope. -- Do not silently change storage formats. -- Use synthetic data and reserved domains such as `example.test`. -- Do not perform live collection unless a separately marked live test explicitly requires it. - -## 6. Test discipline - -Before writing a test, state the failure or contract it protects. Prefer: - -1. pure unit tests for normalization, validation, and migration helpers; -2. contract tests for machine-readable interfaces; -3. one integration test for a complete seam; -4. opt-in live tests under an explicit marker, never as a normal phase exit dependency. - -Do not add tests for line coverage, repeat equivalent CLI-help tests, inspect user-created local databases, depend on absolute home paths, or snapshot broad output when a semantic assertion is sufficient. - -## 7. Completion evidence - -Use `templates/TICKET_COMPLETION.md`. Claims without command output and exit status are not verified. Failed and skipped commands remain visible. Preserve unresolved contradictions rather than inventing certainty. - -## 8. Reviewer protocol - -The reviewer must inspect the complete diff, enforce writable scope, reject unrelated cleanup, independently rerun the smallest gates, inspect migration and secret handling manually, confirm tests map to acceptance criteria, and merge only after dependencies are satisfied. diff --git a/prompt-packs/orchestrator-messaging-evals/MANIFEST.sha256 b/prompt-packs/orchestrator-messaging-evals/MANIFEST.sha256 deleted file mode 100644 index dcbee2d..0000000 --- a/prompt-packs/orchestrator-messaging-evals/MANIFEST.sha256 +++ /dev/null @@ -1,33 +0,0 @@ -a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUNBOOK.md -adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -e05631ee2ae858a4a04dd7522ce2e5caa22267424bf46e1759db2a55b9524c7d README.md -d895854f3a22fdc80e2db0bcc1090d38b29712236365ee084ec4e74aab754af5 pack.yaml -d2d8a5c6f6df66d56f33afc62a0c28d8e6154a3248545f483db9186032327e67 phase-0/MASTER_IMPLEMENTATION_PROMPT.md -758e65dff03aae34fa83cef88035aa1349139c0307341aea35429bf2276e3a48 phase-0/README.md -650be9beb8b5d55d78fcf9f8b4ae45d21f2fb30f638cd2d3b37afe685b8f04cf phase-0/task-manifest.yaml -678ecaec8c33374f36177d5efe2630d0e37adb873062ef2fda9daaec8027757d phase-0/tickets/P0-00-baseline-and-preflight.md -5d9db8a9c979459415bd9fa98a8b92458abb0f4268ebcbb514dd2a74b681d402 phase-1/MASTER_IMPLEMENTATION_PROMPT.md -3e1a8c06794440908b56c70918e0989df47d67c41581b6b60f7f42ec03f33dae phase-1/README.md -baa8a60428ba83b03366f57fc5b88b037163b633e5738a3724e50a3186eb5a3b phase-1/task-manifest.yaml -8466bd6517d28d632a2938d194a3531fe5beb37d3fbbe840d8bf1aee001349ee phase-1/tickets/P1-00-baseline-and-preflight.md -ea0d802a71906712a25507f6e7981c75fe4601f29bb6a36fdd5ab5e222878a1f phase-2/MASTER_IMPLEMENTATION_PROMPT.md -21f7af8d49bd22b668c62eb71658ec218f5ebf8c21f6dc46b478346525bd28d8 phase-2/README.md -d89cc35d90d16e0b551c43fe9b2d8ff103f8ac5890e8e5ea80b48908aca4f3f1 phase-2/task-manifest.yaml -c72dae7a44f74266e42880ddfcf6b7772ee649c73a33d9fbc4c55b3b6dfec5b6 phase-2/tickets/P2-00-baseline-and-preflight.md -df3e1e9413734c729a4e01e335b5f0294a8be21625ca388acc7174b780b97e65 phase-3/MASTER_IMPLEMENTATION_PROMPT.md -397cb16213501f022d3c1e053309e629495e146865273d187f4c081c60d1b589 phase-3/README.md -7f29dffd7f17c7d8abb5b16a2f8fb71f427c12f5067cdca5ce8542f00bfd34dc phase-3/task-manifest.yaml -b0ef35119797f7da5ef42c49c0c298b107a2b0a792202fb9f3f9be180b833095 phase-3/tickets/P3-00-baseline-and-preflight.md -058254d1ca44128deec0c84eeba241a1c96d0c04da5d236fe78250ef42501ddb references/README.md -a0b6f85ecfc917ea634e2d31901765369c5656a8f588c54594ccc8f7ed340d4b references/code-structure-outlines.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 templates/source-baseline.example.json diff --git a/prompt-packs/orchestrator-messaging-evals/README.md b/prompt-packs/orchestrator-messaging-evals/README.md deleted file mode 100644 index afecef1..0000000 --- a/prompt-packs/orchestrator-messaging-evals/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Orchestrator messaging and regression evals - -> Status: historical prompt pack retained for provenance. Its 0.1.4 baseline is intentionally historical, not the current release. - - -## Purpose - -Research, implement, and independently verify durable local parent/child -communication plus two receipt-backed regression evals. The source checkout is -authoritative; `docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md` is the decision -record. - -## Source baseline - -Supplied source archive, release `0.1.4`, reviewed 2026-07-23. - -## Phase map - -| Phase | Objective | Complexity | Exit dependency | -|---|---|---|---| -| 0 | Research existing transports and freeze contracts | Low | Primary sources and a threat model. | -| 1 | Durable messages, wait, and bounded steering | Medium | Phase 0 contract decision. | -| 2 | Metrics and deterministic JSON eval | Medium | Phase 1 sealed control evidence. | -| 3 | Pinned visual child-handoff eval | High | Phase 2 metrics/report contract. | - -## Universal delegation rules - -- Execute every ticket in a fresh named terminal session. -- Use an isolated worktree unless the ticket is explicitly read-only. -- Read required references and current source before editing. -- Follow writable-path restrictions. -- Do not add tests without naming the contract or failure they protect. -- Stop when source contradicts the ticket in a way that could overwrite newer architecture. -- Produce a ticket completion report and preserve all command output. -- Never claim generic live steering for a one-shot executor without a verified - adapter and acknowledgement record. - -## How to execute - -See `EXECUTION_PROTOCOL.md`, `DELEGATION_RUNBOOK.md`, and each phase README. diff --git a/prompt-packs/orchestrator-messaging-evals/pack.yaml b/prompt-packs/orchestrator-messaging-evals/pack.yaml deleted file mode 100644 index 9a55a7b..0000000 --- a/prompt-packs/orchestrator-messaging-evals/pack.yaml +++ /dev/null @@ -1,13 +0,0 @@ -schema: agent-workflow/pack/v1 -pack_id: "orchestrator-messaging-evals" -workflow: - name: agent-workflow - minimum_version: "0.1.4" - terminal_backend: tmux - requires: - - isolated_worktree - - foregroundable_session - - persistent_log - - source_baseline - - completion_report - - independent_phase_gate diff --git a/prompt-packs/orchestrator-messaging-evals/phase-0/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/orchestrator-messaging-evals/phase-0/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 0a47735..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,31 +0,0 @@ -# Phase 0 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. - -## Objective - -Research and freeze the local-first control contract. Produce evidence and stop -before code if the contract would require a service or executor assumption. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-0/README.md b/prompt-packs/orchestrator-messaging-evals/phase-0/README.md deleted file mode 100644 index 82a5295..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-0/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Phase 0 — Prior art and contract freeze - -## Objective - -Produce a source-backed local-first messaging decision. No runtime edits. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P0-00 | C | Low | none | Independent source/link check | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -## Phase-wide constraints - -Primary sources only; disk authority and replay must remain explicit. - -## Required references - -`docs/ORCHESTRATOR_MESSAGING_AND_EVALS_PLAN.md`, tmux and inotify primary docs. - -## Exit gate - -Verify links, contract matrix, threat model, and an explicit non-goal list. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-0/task-manifest.yaml b/prompt-packs/orchestrator-messaging-evals/phase-0/task-manifest.yaml deleted file mode 100644 index b110215..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-0/task-manifest.yaml +++ /dev/null @@ -1,8 +0,0 @@ -phase: "0" -name: "prior-art-and-contract" -mandatory_order: ["P0-00"] -tasks: - - id: "P0-00" - tier: C - session: "orchestrator-messaging-evals-p0-00-research" - prompt: "tickets/P0-00-baseline-and-preflight.md" diff --git a/prompt-packs/orchestrator-messaging-evals/phase-0/tickets/P0-00-baseline-and-preflight.md b/prompt-packs/orchestrator-messaging-evals/phase-0/tickets/P0-00-baseline-and-preflight.md deleted file mode 100644 index 5044f87..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-0/tickets/P0-00-baseline-and-preflight.md +++ /dev/null @@ -1,31 +0,0 @@ -# P0-00 — Research and contract decision - -## Role and objective - -Act as a research lead. Compare local filesystem inbox/outbox, `tmux wait-for` -and control mode, SQLite WAL, Redis Streams, NATS JetStream, and Temporal -signals. Use primary documentation only. Write -`docs/MESSAGING_PRIOR_ART_REPORT.md` with a decision matrix. - -## Required conclusions - -- Choose authority, wakeup accelerator, replay rule, idempotency identity, ack - ordering, retention bound, and failure recovery behavior. -- Explain why arbitrary `tmux send-keys` is not proof of semantic prompt - delivery and what an executor adapter must acknowledge. -- Cover path/symlink, multiwriter, partial-write, duplicate-delivery, payload, - and local-authority threats with concrete mitigations. - -## Writable paths - -`docs/MESSAGING_PRIOR_ART_REPORT.md` only. Do not modify runtime code. - -## Acceptance and stop - -Provide at least five primary-source links and a source-backed recommendation. -Stop if the design requires a new service or unverified executor behavior. - -## Tests - -No runtime tests. Verify each cited link and compare every contract field with -the current source before declaring the research complete. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-1/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/orchestrator-messaging-evals/phase-1/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index f0bbde3..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-1/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,31 +0,0 @@ -# Phase 1 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. - -## Objective - -Implement durable message exchange and replay-based waiting without claiming -late prompt injection into an opaque one-shot executor. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-1/README.md b/prompt-packs/orchestrator-messaging-evals/phase-1/README.md deleted file mode 100644 index c726967..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-1/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Phase 1 — Durable local messaging - -## Objective - -Deliver validated append/replay, progress, steer, ack, and blocking wait. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P1-00 | C | Medium | P0-00 | Independent behavior tests | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -## Phase-wide constraints - -No daemon, broker, database, or fake stdin/keystroke delivery. - -## Required references - -`events.py`, `sessions.py`, `runner.py`, `state.py`, and Phase 0 decision. - -## Exit gate - -Run focused message/session/CLI tests and inspect a replayed pending steer. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-1/task-manifest.yaml b/prompt-packs/orchestrator-messaging-evals/phase-1/task-manifest.yaml deleted file mode 100644 index 2a9455b..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-1/task-manifest.yaml +++ /dev/null @@ -1,8 +0,0 @@ -phase: "1" -name: "durable-local-messaging" -mandatory_order: ["P1-00"] -tasks: - - id: "P1-00" - tier: C - session: "orchestrator-messaging-evals-p1-00-messaging" - prompt: "tickets/P1-00-baseline-and-preflight.md" diff --git a/prompt-packs/orchestrator-messaging-evals/phase-1/tickets/P1-00-baseline-and-preflight.md b/prompt-packs/orchestrator-messaging-evals/phase-1/tickets/P1-00-baseline-and-preflight.md deleted file mode 100644 index 87161e2..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-1/tickets/P1-00-baseline-and-preflight.md +++ /dev/null @@ -1,37 +0,0 @@ -# P1-00 — Durable local messages and bounded steering - -## Objective - -Implement only the Phase-1 local substrate. Add a validated append-only message -log, child `progress`, parent `steer`, and `watch --after SEQUENCE` that blocks -until a durable record or timeout. Disk is authoritative; a tmux wakeup may -reduce latency but waiters always replay. - -## Required behavior - -- Versioned records have UUID idempotency IDs, session binding, contiguous - sequence, flock/fsync, bounded fields, and malformed-tail rejection. -- `progress` is child-to-parent. `steer` is parent-to-child and remains pending - until an explicit child `ack`; no terminal teardown is permitted. -- Reject terminal runs, traversal/symlink paths, arbitrary terminal targets, - invalid directions/kinds, and oversized content. -- Preserve lifecycle `events.jsonl` semantics. Do not add a broker, daemon, - database, remote transport, or generic plugin API. - -## Writable paths - -`src/agent_workflow/messages.py`, `sessions.py`, `cli.py`, `tmux.py`, narrowly -required schemas/receipts, matching tests, and command documentation only. - -## Acceptance criteria - -Parent `watch` returns a durable child update without a status-poll loop; a -steer has a UUID and remains pending until its correlated ack; restart/replay -does not lose or duplicate records. - -## Tests and stop - -Test append/replay, concurrent sequence, corrupt log, restart/replay, timeout, -pending/acknowledged steer, terminal rejection, and legacy launch/status. If a -selected executor closes stdin or has no verified control adapter, leave a steer -durable-and-pending; never fake delivery through shell keystrokes. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-2/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/orchestrator-messaging-evals/phase-2/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 0314518..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-2/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,31 +0,0 @@ -# Phase 2 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. - -## Objective - -Implement sealed normalized metrics and a deterministic fixture only after -Phase 1 control records have a stable schema and tests. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-2/README.md b/prompt-packs/orchestrator-messaging-evals/phase-2/README.md deleted file mode 100644 index a20bad4..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-2/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Phase 2 — Metrics and deterministic eval - -## Objective - -Seal normalized metrics and establish a deterministic regression cohort. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P2-00 | C | Medium | P1-00 | Independent scorer/report run | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -## Phase-wide constraints - -Raw provider events stay authoritative; missing usage is nullable, not zero. - -## Required references - -`executors.py`, `runner.py`, `receipts.py`, `eval/`, and Phase 1 artifacts. - -## Exit gate - -Run fixture baseline/fix/mutation checks and repeat `eval score` on one seal. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-2/task-manifest.yaml b/prompt-packs/orchestrator-messaging-evals/phase-2/task-manifest.yaml deleted file mode 100644 index 144e6e3..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-2/task-manifest.yaml +++ /dev/null @@ -1,8 +0,0 @@ -phase: "2" -name: "metrics-and-deterministic-eval" -mandatory_order: ["P2-00"] -tasks: - - id: "P2-00" - tier: C - session: "orchestrator-messaging-evals-p2-00-metrics" - prompt: "tickets/P2-00-baseline-and-preflight.md" diff --git a/prompt-packs/orchestrator-messaging-evals/phase-2/tickets/P2-00-baseline-and-preflight.md b/prompt-packs/orchestrator-messaging-evals/phase-2/tickets/P2-00-baseline-and-preflight.md deleted file mode 100644 index e0c1171..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-2/tickets/P2-00-baseline-and-preflight.md +++ /dev/null @@ -1,37 +0,0 @@ -# P2-00 — Metrics and deterministic regression eval - -## Objective - -Add sealed normalized metrics/control artifacts and a small deterministic -`normalize_records(records)` regression fixture. Preserve raw executor streams. - -## Required evidence - -- For `orchestrator`, `child:`, `verification`, and `total`, capture nullable - input/cached-input/output/provider-total/cost/currency, monotonic elapsed - time, first-output latency, retry/error records, and steer counts. -- Add JSON schemas; seal artifacts; surface summaries in reports without - recomputing provider facts. -- Fixture uses exact known tests, evaluator-only hidden cases through existing - oracle references, command/scope receipts, mutation failures, and stable - re-scoring. - -## Writable paths - -`schemas/`, runner/executor/receipt/reporting modules, deterministic fixture -files, matching tests, and documentation only. - -## Acceptance criteria - -One sealed run reports nullable stage and total token/cost/time/error facts; -the baseline/mutations fail and the repaired deterministic fixture passes known -and hidden cases; a repeated score has the same verdict. - -## Tests - -Schema validation, provider-usage normalization, runner sealing, fixture, -mutation, oracle-leak, and repeat-score tests are all required. - -## Stop - -Never convert absent cache tokens to zero or infer child activity from prose. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-3/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/orchestrator-messaging-evals/phase-3/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index c4a9ec9..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-3/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1,31 +0,0 @@ -# Phase 3 Master Implementation Prompt - -## Role - -Act as the phase coordinator. Execute or delegate only the tickets listed by `task-manifest.yaml`. - -## Objective - -Build the visual fixture only if browser artifacts and explicit child telemetry -can be imported into the run before sealing. - -## Source-of-truth hierarchy - -Use current source first, then current tests/schemas, then verified references, then documentation and historical plans. - -## Execution rules - -1. Create one clean worktree per writable ticket. -2. Launch every delegation in a new named terminal session. -3. Record source baseline and prompt hash. -4. Enforce dependencies and writable paths. -5. Inspect stalled sessions in the foreground before interruption. -6. Do not merge implementation and independent phase review into the same unchecked delegation. - -## Test policy - -Add only tests required by explicit acceptance criteria or a demonstrated regression boundary. Prefer one semantic assertion over broad snapshots or repeated CLI help coverage. - -## Completion - -Require ticket completion reports, independently rerun phase gates, and produce `PHASE_GATE_REPORT.md`. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-3/README.md b/prompt-packs/orchestrator-messaging-evals/phase-3/README.md deleted file mode 100644 index defaa55..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-3/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Phase 3 — Pinned visual handoff eval - -## Objective - -Exercise explicit parent/child telemetry through a small browser UI task. - -## Complexity and delegation - -| Ticket | Tier | Risk | Dependencies | Reviewer requirement | -|---|---|---|---|---| -| P3-00 | B with A gate | High | P2-00 | Independent browser/receipt gate | - -## Ordering - -Follow `task-manifest.yaml`. Do not execute dependent tickets concurrently. - -## Phase-wide constraints - -Pin browser/image/fonts and never use transcript prose as protocol proof. - -## Required references - -`inspect_adapter.py`, `docs/adr/0001-inspect-evaluation-topology.md`, Phase 2. - -## Exit gate - -Run functional, accessibility, screenshot, negative protocol, and seal checks. diff --git a/prompt-packs/orchestrator-messaging-evals/phase-3/task-manifest.yaml b/prompt-packs/orchestrator-messaging-evals/phase-3/task-manifest.yaml deleted file mode 100644 index b785642..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-3/task-manifest.yaml +++ /dev/null @@ -1,8 +0,0 @@ -phase: "3" -name: "visual-handoff-eval" -mandatory_order: ["P3-00"] -tasks: - - id: "P3-00" - tier: B - session: "orchestrator-messaging-evals-p3-00-visual" - prompt: "tickets/P3-00-baseline-and-preflight.md" diff --git a/prompt-packs/orchestrator-messaging-evals/phase-3/tickets/P3-00-baseline-and-preflight.md b/prompt-packs/orchestrator-messaging-evals/phase-3/tickets/P3-00-baseline-and-preflight.md deleted file mode 100644 index d4dd3ce..0000000 --- a/prompt-packs/orchestrator-messaging-evals/phase-3/tickets/P3-00-baseline-and-preflight.md +++ /dev/null @@ -1,39 +0,0 @@ -# P3-00 — Visual orchestration-child regression eval - -## Objective - -Build a tiny priority-picker web UI eval in a pinned offline browser container. -The parent must produce explicit child telemetry (`child_started`, request, -result, `child_finished`) before implementation/verification proceeds. - -## Required behavior - -- Fixed 1280x720 viewport, pinned browser/fonts, disabled animation, click and - Escape paths, ARIA assertions, deterministic screenshot tolerance, and DOM - assertions as the primary judge. -- Copy or manifest Inspect/browser transcript, screenshot, test report, and - telemetry inside the run before sealing. A model transcript alone is not - child-handoff proof. -- Negative fixtures fail for no child, late/invalid child result, ignored child - result, broken Escape, visual mismatch, and scope escape. - -## Writable paths - -The new pinned UI fixture, Inspect evidence bridge, sealed telemetry/metrics -interfaces, matching tests, and documentation only. - -## Acceptance criteria - -Pinned functional and accessibility tests pass only with valid child telemetry; -the browser evidence is present in the final receipt; every listed negative -fixture fails for its intended reason. - -## Tests - -Run offline functional/ARIA/screenshot tests plus negative protocol and receipt -verification tests; record image/browser/font versions in evidence. - -## Stop - -If browser/image versions cannot be pinned or evidence cannot enter the final -receipt before sealing, deliver fixture design and a blocked gate report only. diff --git a/prompt-packs/orchestrator-messaging-evals/references/README.md b/prompt-packs/orchestrator-messaging-evals/references/README.md deleted file mode 100644 index 34e423e..0000000 --- a/prompt-packs/orchestrator-messaging-evals/references/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# References - -Include verified revisions, current source excerpts, architecture and authority boundaries, code-structure outlines, compatibility decisions, and the project-specific test policy. References are evidence and constraints; current checked-out source remains authoritative. diff --git a/prompt-packs/orchestrator-messaging-evals/references/code-structure-outlines.md b/prompt-packs/orchestrator-messaging-evals/references/code-structure-outlines.md deleted file mode 100644 index a5314db..0000000 --- a/prompt-packs/orchestrator-messaging-evals/references/code-structure-outlines.md +++ /dev/null @@ -1,14 +0,0 @@ -# Code Structure Outlines - -For each proposed module or interface, include: - -1. ownership and authority boundary; -2. exact target path; -3. public types/functions and signatures; -4. persistence or wire-format shape; -5. error/outcome taxonomy; -6. migration and compatibility behavior; -7. tests justified by real contracts; -8. explicitly excluded adjacent work. - -Outlines constrain implementation; they are not permission to rewrite unrelated code. diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/archive-prompt-pack.sh b/prompt-packs/orchestrator-messaging-evals/scripts/archive-prompt-pack.sh deleted file mode 100755 index eef4a85..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/archive-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 2 ]] || { echo "usage: $0 PACK_DIR OUTPUT.tar.zst" >&2; exit 2; } -exec agent-workflow pack archive "$1" "$2" diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/check-delegation.sh b/prompt-packs/orchestrator-messaging-evals/scripts/check-delegation.sh deleted file mode 100755 index 65fc1d5..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/check-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow status "$1" --capture 40 diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/create-ticket-worktree.sh b/prompt-packs/orchestrator-messaging-evals/scripts/create-ticket-worktree.sh deleted file mode 100755 index 262d9e0..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/create-ticket-worktree.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 3 && $# -le 4 ]] || { echo "usage: $0 REPO_PATH TICKET_ID BASE_REF [DEST]" >&2; exit 2; } -args=(worktree create "$1" "$2" "$3") -[[ $# -ge 4 ]] && args+=(--dest "$4") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/foreground-delegation.sh b/prompt-packs/orchestrator-messaging-evals/scripts/foreground-delegation.sh deleted file mode 100755 index a9cc4d6..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/foreground-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow attach "$1" diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/launch-delegation.sh b/prompt-packs/orchestrator-messaging-evals/scripts/launch-delegation.sh deleted file mode 100755 index 256ca35..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/launch-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 5 ]] || { echo "usage: $0 SESSION WORKDIR PROMPT_FILE -- COMMAND [ARGS...]" >&2; exit 2; } -exec agent-workflow launch "$@" diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/restart-delegation.sh b/prompt-packs/orchestrator-messaging-evals/scripts/restart-delegation.sh deleted file mode 100755 index 65ec25d..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/restart-delegation.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 1 && $# -le 2 ]] || { echo "usage: $0 SESSION [NEW_SESSION]" >&2; exit 2; } -args=(restart "$1") -[[ $# -eq 2 ]] && args+=(--new-session "$2") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/stop-delegation.sh b/prompt-packs/orchestrator-messaging-evals/scripts/stop-delegation.sh deleted file mode 100755 index 7f1ad48..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/stop-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow terminate "$1" diff --git a/prompt-packs/orchestrator-messaging-evals/scripts/validate-prompt-pack.sh b/prompt-packs/orchestrator-messaging-evals/scripts/validate-prompt-pack.sh deleted file mode 100755 index 1478e7d..0000000 --- a/prompt-packs/orchestrator-messaging-evals/scripts/validate-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 PACK_DIR" >&2; exit 2; } -exec agent-workflow pack validate "$1" diff --git a/prompt-packs/orchestrator-messaging-evals/templates/PHASE_GATE_REPORT.md b/prompt-packs/orchestrator-messaging-evals/templates/PHASE_GATE_REPORT.md deleted file mode 100644 index 5fbb185..0000000 --- a/prompt-packs/orchestrator-messaging-evals/templates/PHASE_GATE_REPORT.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -schema: agent-workflow/phase-gate/v1 -pack_id: "" -phase: "" -review_session: "" -decision: "accepted|rejected|accepted_with_follow_up" ---- - -# Phase Gate Report - -## Ticket status - -| Ticket | Branch/commit | Review result | Notes | -|---|---|---|---| - -## Independent gate commands - -| Command | Exit code | Result summary | -|---|---:|---| - -## Boundary audit - -- [ ] Authority and ownership boundaries remain intact. -- [ ] No unexpected data migration or secret exposure occurred. -- [ ] No unsupported flags, paths, or compatibility claims remain in phase scope. -- [ ] Tests correspond to real contracts or failures. -- [ ] Documentation and skills do not claim unimplemented behavior. -- [ ] Changed files stayed inside ticket writable scopes. - -## Rejected or deferred work - -## Decision rationale diff --git a/prompt-packs/orchestrator-messaging-evals/templates/TICKET_COMPLETION.md b/prompt-packs/orchestrator-messaging-evals/templates/TICKET_COMPLETION.md deleted file mode 100644 index 7f45e85..0000000 --- a/prompt-packs/orchestrator-messaging-evals/templates/TICKET_COMPLETION.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: "" -phase: "" -ticket: "" -session: "" -result: "completed|partial|failed|blocked" -base_revision: "" -head_revision: "" ---- - -# Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| - -## Scope delivered - -Describe only what was actually changed. - -## Files changed - -```text - -``` - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| | pass/fail/not verified | command/file | - -## Tests and validation - -| Command | Exit code | Contract or failure protected | -|---|---:|---| - -## Tests intentionally not added - -Explain why broader unit, snapshot, CLI-help, local-file, or live tests would be redundant or out of scope. - -## Migration and compatibility notes - -State migration behavior, rollback/recovery behavior, and intentionally unsupported legacy paths. - -## Unresolved issues or source contradictions - -Do not hide uncertainties. - -## No-drift declaration - -- [ ] No files outside writable scope changed. -- [ ] No superfluous tests were added. -- [ ] No live target collection was performed. -- [ ] No compatibility layer was added outside the ticket. -- [ ] Documentation claims were verified against current source before implementation. diff --git a/prompt-packs/orchestrator-messaging-evals/templates/source-baseline.example.json b/prompt-packs/orchestrator-messaging-evals/templates/source-baseline.example.json deleted file mode 100644 index 3fcb45a..0000000 --- a/prompt-packs/orchestrator-messaging-evals/templates/source-baseline.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schema": "agent-workflow/source-baseline/v1", - "generated_at": "", - "components": { - "primary": { - "path": "", - "head": "", - "branch": "", - "dirty": false - } - } -} diff --git a/prompt-packs/workflow-foundations-next/CHATGPT_HANDOFF_PROMPT.md b/prompt-packs/workflow-foundations-next/CHATGPT_HANDOFF_PROMPT.md deleted file mode 100644 index 5fc4531..0000000 --- a/prompt-packs/workflow-foundations-next/CHATGPT_HANDOFF_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Execute this prompt pack against the current agent-workflow source. First verify the already implemented dependency validation and structured task-result collection. Then complete as much remaining work as is safe, preserving `agent-workflow launch` as the sole execution authority. Do not implement any listed non-target. Update tests, schemas, backlog, architecture, README, and release assets for every completed contract. Stop rather than introducing speculative abstractions. diff --git a/prompt-packs/workflow-foundations-next/DELEGATION_RUNBOOK.md b/prompt-packs/workflow-foundations-next/DELEGATION_RUNBOOK.md deleted file mode 100644 index a2fa88a..0000000 --- a/prompt-packs/workflow-foundations-next/DELEGATION_RUNBOOK.md +++ /dev/null @@ -1,61 +0,0 @@ -# Delegation Runbook - -## Preflight - -```bash -agent-workflow doctor -agent-workflow config show -agent-workflow pack validate /path/to/prompt-pack -agent-workflow worktree create /path/to/repository P0-01 HEAD -``` - -## Launch in a fresh terminal - -```bash -agent-workflow launch project-p0-01-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-01.md --ticket P0-01 --pack project-phases-0-2 --executor codex -agent-workflow launch project-p0-02-components /path/to/worktree /path/to/pack/phase-0/tickets/P0-02.md --ticket P0-02 --pack project-phases-0-2 --executor claude -``` - -The prompt is passed to the command over standard input. - -## Workflow skills - -| Purpose | Codex | Claude | -|---|---|---| -| Build a prompt pack | `$prompt-pack-builder` | `/prompt-pack-builder` | -| Implement one ticket | `$delegated-implementation` | `/delegated-implementation` | -| Review a completed phase | `$phase-gate-review` | `/phase-gate-review` | - -## Observe and foreground - -```bash -agent-workflow list -agent-workflow status project-p0-01 --capture 60 -agent-workflow attach project-p0-01 -agent-workflow tail project-p0-01 -``` - -`possibly_stalled` is advisory. It means tmux is alive while the log has not grown during the configured threshold. - -## Stall handling - -1. Run `status --capture 100`. -2. Attach to the session. -3. Classify input wait, package/network wait, test deadlock, model loop, or legitimate long operation. -4. Interrupt without deleting evidence. -5. Correct the prompt or environment. -6. Restart into a new `-retryN` session. - -## Stop controls - -```bash -agent-workflow interrupt SESSION -agent-workflow terminate SESSION --grace-seconds 8 -agent-workflow kill SESSION -``` - -Use immediate kill only for an unresponsive process. All controls preserve durable evidence. - -## Completion and review - -Require a ticket completion report, inspect the diff before running tests, independently rerun narrow acceptance commands, and create a phase-gate report. A high-risk implementer should not be the only reviewer. diff --git a/prompt-packs/workflow-foundations-next/EXECUTION_PROTOCOL.md b/prompt-packs/workflow-foundations-next/EXECUTION_PROTOCOL.md deleted file mode 100644 index 132cc39..0000000 --- a/prompt-packs/workflow-foundations-next/EXECUTION_PROTOCOL.md +++ /dev/null @@ -1,73 +0,0 @@ -# Execution Protocol - -## 1. Source-of-truth hierarchy - -Use this order when sources disagree: - -1. current checked-out source and argument parsers; -2. current schemas, migrations, and package metadata; -3. tests that exercise current runtime behavior; -4. verified review findings and source excerpts; -5. current README, man page, and skill claims; -6. historical plans and progress notes. - -Never implement a historical “completed” claim without confirming that the behavior exists on the checked-out revision. - -## 2. Required preflight for every ticket - -Record: - -```bash -pwd -git status --short -git rev-parse --show-toplevel -git rev-parse HEAD -git branch --show-current -python3 --version -``` - -For a multi-repository workspace, record the same data for every repository touched. Inspect every path named by the ticket before editing it. If a path has moved, locate the current equivalent and record the mapping; do not recreate removed files merely to match an old prompt. - -## 3. Drift handling - -- If the source matches the reviewed shape, implement the ticket. -- If the source already contains a correct implementation, verify it and limit work to missing acceptance gates. -- If the source partially changed, adapt narrowly and document the delta. -- If the ticket would overwrite newer architecture, schema, or migration work, stop and escalate. -- Never broaden writable paths without explicit authorization. - -## 4. New-terminal and observability rule - -Every delegation runs in a fresh named `tmux` session. The session name includes project/pack, phase, and ticket identity. The session must be foregroundable and must write a live persistent log. - -A delegation is only **potentially** stalled when its terminal is alive and the live log has not changed for the configured interval. Foreground and inspect before interrupting it. Never automatically kill a session merely because a timer elapsed. - -## 5. Implementation discipline - -- Read before editing. -- Make the smallest coherent change. -- Prefer removing a contradictory authorized surface over adding compatibility indirection. -- Do not add a framework, service, database, UI, worker, or build system unless the ticket requires it. -- Do not rename public interfaces outside ticket scope. -- Do not silently change storage formats. -- Use synthetic data and reserved domains such as `example.test`. -- Do not perform live collection unless a separately marked live test explicitly requires it. - -## 6. Test discipline - -Before writing a test, state the failure or contract it protects. Prefer: - -1. pure unit tests for normalization, validation, and migration helpers; -2. contract tests for machine-readable interfaces; -3. one integration test for a complete seam; -4. opt-in live tests under an explicit marker, never as a normal phase exit dependency. - -Do not add tests for line coverage, repeat equivalent CLI-help tests, inspect user-created local databases, depend on absolute home paths, or snapshot broad output when a semantic assertion is sufficient. - -## 7. Completion evidence - -Use `templates/TICKET_COMPLETION.md`. Claims without command output and exit status are not verified. Failed and skipped commands remain visible. Preserve unresolved contradictions rather than inventing certainty. - -## 8. Reviewer protocol - -The reviewer must inspect the complete diff, enforce writable scope, reject unrelated cleanup, independently rerun the smallest gates, inspect migration and secret handling manually, confirm tests map to acceptance criteria, and merge only after dependencies are satisfied. diff --git a/prompt-packs/workflow-foundations-next/MANIFEST.sha256 b/prompt-packs/workflow-foundations-next/MANIFEST.sha256 deleted file mode 100644 index b848828..0000000 --- a/prompt-packs/workflow-foundations-next/MANIFEST.sha256 +++ /dev/null @@ -1,36 +0,0 @@ -7463d40674de941d9a181193befa920f66f9b1c127edbc847f0bd628cbd296c6 CHATGPT_HANDOFF_PROMPT.md -a7c7a7b898a9b93b3676cc04f41736a8b3e20ce8b7d27433f8553e6600085bd9 DELEGATION_RUNBOOK.md -adb8bb73956cbca673ff90acb16d058396b95f8d4946700947a6743d63d2caf1 EXECUTION_PROTOCOL.md -068d67532b134b833a122a4b442b6adb31e8dc613858036b20d0f26f1d3f1ec4 README.md -a34bd83458605c47cc4f74aa3c1261dc4f6f185647521e8916738d46508d1276 contracts/workflow-node-result.schema.json -8bfb8d3eaae1cd49dcb33ec4814dbe7f17fec17eecb8f8b10239d913d8b16c9f pack.yaml -f7523df45911ef669bcc812d26f8cef88a1ca8f8aa2f4fe5ac05dbd5a2321247 phase-0/MASTER_IMPLEMENTATION_PROMPT.md -c72159dcbc2f216655a37e25bc1c843219ce0d0d0376369b3902a96f60459e80 phase-0/README.md -da114439c42bc72666375c5f693ad3a975fe2deb931963c3d038de5117f63c2e phase-0/task-manifest.yaml -325f6722e52dda55f8b565640f2dfa71bb25c12a7a44a6c078a1db7ee62416de phase-0/tickets/WF-00-contract-and-state.md -8d0d431572c396e5f580455c459367f84d1ba375da5f74a6230bea37edfe59c5 phase-0/tickets/WF-01-scheduler-service.md -0ed1811b6960deca79bdf16ad6601ecf1f39d5a8d8841c2af001bba42015269d phase-0/tickets/WF-02-restart-and-cli.md -be0c18b729ce86203318c5f0604f800f197026dc38d70336009a97fdbdfa6f05 phase-1/MASTER_IMPLEMENTATION_PROMPT.md -7ce12c4c9bb6ecd96c2cb3210063cf5f8a1fba4e7cbdbec061728e1ba58981f9 phase-1/README.md -1586d81810dca16935cf763d16f15a2f0e526674e60b5eae8a1beaf5d74a179e phase-1/task-manifest.yaml -b595b2a035988910d0e7315852d0f3d9b68b4039d8dd9e8a5a6726794faaf22e phase-1/tickets/WF-10-approval-gates.md -0a6f2bcb59ef8b1abd2a038dbe7344ca78e5872c3b5269e4d09fcf330955e20b phase-1/tickets/WF-11-result-binding.md -2ad9a00fb19ddd2533d6db54c97f1805fc3bf91ad1ec2571cebf17b78ff3839d phase-1/tickets/WF-12-workflow-receipt.md -54511b2b64f1b90dca042b12bea9bed0080a6f89f8b910f2120c24079ae3f17c phase-2/MASTER_IMPLEMENTATION_PROMPT.md -e4b9938f2d00d52f56d288ac97751b7e4972053d36e9c54ad6133164c10d937a phase-2/README.md -b1a5a2c32148f7adb1e6b4df2753823ee7c087d4c8ed5b97dba0e7e31884f800 phase-2/task-manifest.yaml -e4a9948cda12a880e465ddc911f06583f96aaf5f520d8494ec539a9d17974d9b phase-2/tickets/WF-20-templates.md -d4932ccc4b6c4734745451cd76c439b42e78cdd36d7289d020bea44f5e5ddeb3 phase-2/tickets/WF-21-routing-advice.md -504c047b3cb460439975726ca8b891acdd0f5c6b379d196541c4cf5a6ae8064e phase-2/tickets/WF-22-integration-review.md -2276f05f42f628925cd0b0834d7044e5934a488f85f4a57d5b22b023009ae17f references/README.md -4e8ebf35462e41827b99166131be36ab5270049ada97b25376f7505e40d465be scripts/archive-prompt-pack.sh -004436d4173b3421f4f64c4423164b9b447b9f17c7ca19e215e570f41d9ab706 scripts/check-delegation.sh -0b691f2dfa4d7805ccacb77a48b27435a089678e51e43bb59d34f5a55898ae12 scripts/create-ticket-worktree.sh -040e8c711c5f0049f90d1e07fbf34481212208ec018f6942af416cfa3bc30672 scripts/foreground-delegation.sh -944a3a262ab22cf900bcd3b5520046a55eab2ee9a54065900de833d616f26720 scripts/launch-delegation.sh -964c3c2b4488236c6123923f5597c6da74505ef000600ec559462da5d8336f33 scripts/restart-delegation.sh -19db5cd84d530d94c34afef97948076a6e8e9c8aff3340d7f4b4a7937cc11f63 scripts/stop-delegation.sh -614862cf59ce369869b277d408e839c50a4a1c81082546dcfb649d44ae02dd5f scripts/validate-prompt-pack.sh -ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c templates/PHASE_GATE_REPORT.md -c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 templates/TICKET_COMPLETION.md -29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 templates/source-baseline.example.json diff --git a/prompt-packs/workflow-foundations-next/README.md b/prompt-packs/workflow-foundations-next/README.md deleted file mode 100644 index 6a7e375..0000000 --- a/prompt-packs/workflow-foundations-next/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# workflow-foundations-next - -Implement only the remaining workflow foundations authorized by `docs/WORKFLOW_FOUNDATIONS_PLAN.md`: restart-safe dependency scheduling, receipt-backed approval gates, bounded structured-result binding, three reusable graph templates, and deterministic routing explanations. - -This pack must not add an external orchestrator, arbitrary workflow scripts, memory/learning infrastructure, named persona catalogs, federation, consensus mechanisms, or a second execution path. Every child run continues through `agent-workflow launch` and existing policy enforcement. - - -## Sequencing with MCP - -Complete this pack through `WF-22` before executing Phase 3 of -`prompt-packs/mcp-server-next`. The existing read-only MCP server does not need -to be reimplemented. Remaining MCP mutation tools must consume the stable -workflow services produced here and must not duplicate scheduling, routing, -approval, or receipt logic. diff --git a/prompt-packs/workflow-foundations-next/contracts/workflow-node-result.schema.json b/prompt-packs/workflow-foundations-next/contracts/workflow-node-result.schema.json deleted file mode 100644 index 109006c..0000000 --- a/prompt-packs/workflow-foundations-next/contracts/workflow-node-result.schema.json +++ /dev/null @@ -1 +0,0 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["implemented","tests","unresolved"],"properties":{"implemented":{"type":"array","items":{"type":"string"}},"tests":{"type":"array","items":{"type":"string"}},"unresolved":{"type":"array","items":{"type":"string"}}},"additionalProperties":false} diff --git a/prompt-packs/workflow-foundations-next/pack.yaml b/prompt-packs/workflow-foundations-next/pack.yaml deleted file mode 100644 index f2c93ec..0000000 --- a/prompt-packs/workflow-foundations-next/pack.yaml +++ /dev/null @@ -1,8 +0,0 @@ -schema: agent-workflow/pack/v1 -pack_id: workflow-foundations-next -name: Workflow Foundations Next -version: 1 -phases: - - phase-0 - - phase-1 - - phase-2 diff --git a/prompt-packs/workflow-foundations-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/workflow-foundations-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index ee998ec..0000000 --- a/prompt-packs/workflow-foundations-next/phase-0/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Implement Phase 0 tickets in dependency order. Reuse existing state, launch, lifecycle, and receipt services. The workflow event journal is append-only and authoritative; any status snapshot must be reconstructable. diff --git a/prompt-packs/workflow-foundations-next/phase-0/README.md b/prompt-packs/workflow-foundations-next/phase-0/README.md deleted file mode 100644 index 94012ad..0000000 --- a/prompt-packs/workflow-foundations-next/phase-0/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Phase 0 — Scheduler core - -Implement the smallest restart-safe dependency scheduler over validated prompt packs. No approval, templates, or learned routing in this phase. diff --git a/prompt-packs/workflow-foundations-next/phase-0/task-manifest.yaml b/prompt-packs/workflow-foundations-next/phase-0/task-manifest.yaml deleted file mode 100644 index 2d75ee7..0000000 --- a/prompt-packs/workflow-foundations-next/phase-0/task-manifest.yaml +++ /dev/null @@ -1,27 +0,0 @@ -phase: 0 -name: scheduler-core -mandatory_order: [WF-00, WF-01, WF-02] -tasks: - - id: WF-00 - tier: B - session: workflow-foundations-wf-00 - prompt: tickets/WF-00-contract-and-state.md - result_contract: - schema: contracts/workflow-node-result.schema.json - required: true - - id: WF-01 - tier: A - session: workflow-foundations-wf-01 - prompt: tickets/WF-01-scheduler-service.md - dependencies: [WF-00] - result_contract: - schema: contracts/workflow-node-result.schema.json - required: true - - id: WF-02 - tier: A - session: workflow-foundations-wf-02 - prompt: tickets/WF-02-restart-and-cli.md - dependencies: [WF-01] - result_contract: - schema: contracts/workflow-node-result.schema.json - required: true diff --git a/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md b/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md deleted file mode 100644 index 9bdb037..0000000 --- a/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-00-contract-and-state.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-00 — Workflow contract and durable state - -Define the minimal workflow-run schema, immutable pack snapshot, node/run binding, append-only event envelope, and reconstructable status projection. Writable: workflow schemas, workflow domain module, tests, directly related docs. Acceptance: unknown/cyclic graphs remain rejected; status can be rebuilt solely from snapshot plus events; no mutable store becomes authoritative. Test all transitions and corruption handling. Stop if implementation requires arbitrary workflow code or a second executor path. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md b/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md deleted file mode 100644 index ab6f19a..0000000 --- a/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-01-scheduler-service.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-01 — Dependency scheduler service - -Implement eligibility calculation, bounded parallel launch planning, dependency failure propagation, and retry lineage. Invoke the existing launch domain service; do not shell out to the CLI or spawn executors directly. Writable: workflow service, narrow launch seam refactor if required, tests, docs. Acceptance: each eligible node launches at most once per binding; restart replay is idempotent; parallelism is bounded; failed prerequisites block dependents. Stop on policy ambiguity. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md b/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md deleted file mode 100644 index f6c6fd2..0000000 --- a/prompt-packs/workflow-foundations-next/phase-0/tickets/WF-02-restart-and-cli.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-02 — Restart recovery and CLI - -Add minimal `workflow validate`, `workflow start`, `workflow status`, and `workflow resume` commands over the scheduler service. Writable: CLI, workflow service, tests, docs. Acceptance: JSON output is stable and bounded; resume reconstructs state before scheduling; CLI and future MCP adapters share the same service; no HTTP or daemon is added. Test restart, duplicate invocation, invalid roots, and terminal workflows. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/workflow-foundations-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 94cc64c..0000000 --- a/prompt-packs/workflow-foundations-next/phase-1/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Implement receipt-backed approval gates, downstream JSON Pointer binding, and an aggregate workflow receipt. Keep all inputs bounded and provenance-sealed. diff --git a/prompt-packs/workflow-foundations-next/phase-1/README.md b/prompt-packs/workflow-foundations-next/phase-1/README.md deleted file mode 100644 index 4b62fbd..0000000 --- a/prompt-packs/workflow-foundations-next/phase-1/README.md +++ /dev/null @@ -1 +0,0 @@ -# Phase 1 — Evidence gates and result binding diff --git a/prompt-packs/workflow-foundations-next/phase-1/task-manifest.yaml b/prompt-packs/workflow-foundations-next/phase-1/task-manifest.yaml deleted file mode 100644 index 479ab6f..0000000 --- a/prompt-packs/workflow-foundations-next/phase-1/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: 1 -name: gates-and-binding -mandatory_order: [WF-10, WF-11, WF-12] -tasks: - - id: WF-10 - tier: A - session: workflow-foundations-wf-10 - prompt: tickets/WF-10-approval-gates.md - dependencies: [WF-02] - - id: WF-11 - tier: A - session: workflow-foundations-wf-11 - prompt: tickets/WF-11-result-binding.md - dependencies: [WF-10] - - id: WF-12 - tier: A - session: workflow-foundations-wf-12 - prompt: tickets/WF-12-workflow-receipt.md - dependencies: [WF-11] diff --git a/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md b/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md deleted file mode 100644 index b3f2cea..0000000 --- a/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-10-approval-gates.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-10 — Receipt-backed approval gates - -Add an approval node satisfied only by a valid review/lifecycle receipt referencing the expected child final-receipt digest. No mutable approval boolean is authoritative. Acceptance: accepted and rejected paths are distinct, tampered or unrelated receipts fail closed, and downstream eligibility follows durable evidence. Update tests and docs. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md b/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md deleted file mode 100644 index 8ab5b08..0000000 --- a/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-11-result-binding.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-11 — Structured result binding - -Allow declared downstream inputs to select fields from validated predecessor `result.json` using JSON Pointer. Snapshot resolved values and source digests into child provenance. Enforce size limits and fail closed on missing required fields. Do not add an expression or template language. Test malformed pointers, oversized values, absent results, and retry lineage. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md b/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md deleted file mode 100644 index 347e3f2..0000000 --- a/prompt-packs/workflow-foundations-next/phase-1/tickets/WF-12-workflow-receipt.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-12 — Aggregate workflow receipt - -Seal a terminal workflow receipt containing the workflow snapshot digest, event-log digest, node IDs, bound run IDs, child final-receipt digests, approval receipt digests, and terminal outcome. Provide verification. Test substitution, omission, duplicate nodes, and partial workflows. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md b/prompt-packs/workflow-foundations-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md deleted file mode 100644 index 20a3da3..0000000 --- a/prompt-packs/workflow-foundations-next/phase-2/MASTER_IMPLEMENTATION_PROMPT.md +++ /dev/null @@ -1 +0,0 @@ -Add only three graph templates and deterministic routing explanations. No learning system, personas, or hidden hooks. diff --git a/prompt-packs/workflow-foundations-next/phase-2/README.md b/prompt-packs/workflow-foundations-next/phase-2/README.md deleted file mode 100644 index a9722ee..0000000 --- a/prompt-packs/workflow-foundations-next/phase-2/README.md +++ /dev/null @@ -1 +0,0 @@ -# Phase 2 — Small templates and routing explanations diff --git a/prompt-packs/workflow-foundations-next/phase-2/task-manifest.yaml b/prompt-packs/workflow-foundations-next/phase-2/task-manifest.yaml deleted file mode 100644 index 3e0acf5..0000000 --- a/prompt-packs/workflow-foundations-next/phase-2/task-manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -phase: 2 -name: templates-and-routing -mandatory_order: [WF-20, WF-21, WF-22] -tasks: - - id: WF-20 - tier: B - session: workflow-foundations-wf-20 - prompt: tickets/WF-20-templates.md - dependencies: [WF-12] - - id: WF-21 - tier: A - session: workflow-foundations-wf-21 - prompt: tickets/WF-21-routing-advice.md - dependencies: [WF-20] - - id: WF-22 - tier: A - session: workflow-foundations-wf-22 - prompt: tickets/WF-22-integration-review.md - dependencies: [WF-21] diff --git a/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md b/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md deleted file mode 100644 index a017bce..0000000 --- a/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-20-templates.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-20 — Three reusable workflow templates - -Implement validated expansion for pipeline, parallel-review/fan-in, and implementation-plus-independent-review. Expansion must produce the canonical workflow schema. Do not add methodology or agent-persona catalogs. Test deterministic expansion and invalid parameters. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md b/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md deleted file mode 100644 index 59f0595..0000000 --- a/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-21-routing-advice.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-21 — Deterministic routing advice - -Implement a pure advisory function over existing task metadata that recommends current agent class, executor, model, and interactive mode with stable explanation codes. Existing configuration enforcement remains authoritative. Record recommendation, enforced selection, and policy disagreement separately. No embeddings, online learning, or config mutation. Test all rules and no-go rejection. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md b/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md deleted file mode 100644 index 4086203..0000000 --- a/prompt-packs/workflow-foundations-next/phase-2/tickets/WF-22-integration-review.md +++ /dev/null @@ -1,8 +0,0 @@ -# WF-22 — Critical integration review - -Review scheduler, gates, binding, templates, and routing against current tmux, interactive session, naming, model policy, MCP, receipts, and global routing contracts. Remove speculative abstractions and stale docs. Run the full suite and release audit. Acceptance: no alternate launch path, no hidden mutable authority, no external-project terminology, and all backlog states/evidence are current. - - -## Execution constraints - -Writable paths are limited to the files named by this ticket and directly related tests and documentation. Acceptance requires focused tests plus the relevant full-suite slice. Stop if current source contradicts the ticket or the change would introduce a listed non-target. diff --git a/prompt-packs/workflow-foundations-next/references/README.md b/prompt-packs/workflow-foundations-next/references/README.md deleted file mode 100644 index 20caa04..0000000 --- a/prompt-packs/workflow-foundations-next/references/README.md +++ /dev/null @@ -1,9 +0,0 @@ -Read the repository copies of: - -- `docs/WORKFLOW_FOUNDATIONS_PLAN.md` -- `docs/GLOBAL_AGENT_ROUTING.md` -- `docs/ARCHITECTURE.md` -- `BACKLOG.md` -- the current prompt-pack, runner, receipts, lifecycle, config, and session code - -The extracted source is authoritative over this pack. diff --git a/prompt-packs/workflow-foundations-next/scripts/archive-prompt-pack.sh b/prompt-packs/workflow-foundations-next/scripts/archive-prompt-pack.sh deleted file mode 100755 index eef4a85..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/archive-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 2 ]] || { echo "usage: $0 PACK_DIR OUTPUT.tar.zst" >&2; exit 2; } -exec agent-workflow pack archive "$1" "$2" diff --git a/prompt-packs/workflow-foundations-next/scripts/check-delegation.sh b/prompt-packs/workflow-foundations-next/scripts/check-delegation.sh deleted file mode 100755 index 65fc1d5..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/check-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow status "$1" --capture 40 diff --git a/prompt-packs/workflow-foundations-next/scripts/create-ticket-worktree.sh b/prompt-packs/workflow-foundations-next/scripts/create-ticket-worktree.sh deleted file mode 100755 index 262d9e0..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/create-ticket-worktree.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 3 && $# -le 4 ]] || { echo "usage: $0 REPO_PATH TICKET_ID BASE_REF [DEST]" >&2; exit 2; } -args=(worktree create "$1" "$2" "$3") -[[ $# -ge 4 ]] && args+=(--dest "$4") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/workflow-foundations-next/scripts/foreground-delegation.sh b/prompt-packs/workflow-foundations-next/scripts/foreground-delegation.sh deleted file mode 100755 index a9cc4d6..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/foreground-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow attach "$1" diff --git a/prompt-packs/workflow-foundations-next/scripts/launch-delegation.sh b/prompt-packs/workflow-foundations-next/scripts/launch-delegation.sh deleted file mode 100755 index 256ca35..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/launch-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 5 ]] || { echo "usage: $0 SESSION WORKDIR PROMPT_FILE -- COMMAND [ARGS...]" >&2; exit 2; } -exec agent-workflow launch "$@" diff --git a/prompt-packs/workflow-foundations-next/scripts/restart-delegation.sh b/prompt-packs/workflow-foundations-next/scripts/restart-delegation.sh deleted file mode 100755 index 65ec25d..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/restart-delegation.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -ge 1 && $# -le 2 ]] || { echo "usage: $0 SESSION [NEW_SESSION]" >&2; exit 2; } -args=(restart "$1") -[[ $# -eq 2 ]] && args+=(--new-session "$2") -exec agent-workflow "${args[@]}" diff --git a/prompt-packs/workflow-foundations-next/scripts/stop-delegation.sh b/prompt-packs/workflow-foundations-next/scripts/stop-delegation.sh deleted file mode 100755 index 7f1ad48..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/stop-delegation.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 SESSION" >&2; exit 2; } -exec agent-workflow terminate "$1" diff --git a/prompt-packs/workflow-foundations-next/scripts/validate-prompt-pack.sh b/prompt-packs/workflow-foundations-next/scripts/validate-prompt-pack.sh deleted file mode 100755 index 1478e7d..0000000 --- a/prompt-packs/workflow-foundations-next/scripts/validate-prompt-pack.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -[[ $# -eq 1 ]] || { echo "usage: $0 PACK_DIR" >&2; exit 2; } -exec agent-workflow pack validate "$1" diff --git a/prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md b/prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md deleted file mode 100644 index 5fbb185..0000000 --- a/prompt-packs/workflow-foundations-next/templates/PHASE_GATE_REPORT.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -schema: agent-workflow/phase-gate/v1 -pack_id: "" -phase: "" -review_session: "" -decision: "accepted|rejected|accepted_with_follow_up" ---- - -# Phase Gate Report - -## Ticket status - -| Ticket | Branch/commit | Review result | Notes | -|---|---|---|---| - -## Independent gate commands - -| Command | Exit code | Result summary | -|---|---:|---| - -## Boundary audit - -- [ ] Authority and ownership boundaries remain intact. -- [ ] No unexpected data migration or secret exposure occurred. -- [ ] No unsupported flags, paths, or compatibility claims remain in phase scope. -- [ ] Tests correspond to real contracts or failures. -- [ ] Documentation and skills do not claim unimplemented behavior. -- [ ] Changed files stayed inside ticket writable scopes. - -## Rejected or deferred work - -## Decision rationale diff --git a/prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md b/prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md deleted file mode 100644 index 7f45e85..0000000 --- a/prompt-packs/workflow-foundations-next/templates/TICKET_COMPLETION.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -schema: agent-workflow/ticket-completion/v1 -pack_id: "" -phase: "" -ticket: "" -session: "" -result: "completed|partial|failed|blocked" -base_revision: "" -head_revision: "" ---- - -# Ticket Completion Report - -## Source baseline - -| Repository/component | Revision before | Revision after | Dirty before | -|---|---|---|---| - -## Scope delivered - -Describe only what was actually changed. - -## Files changed - -```text - -``` - -## Acceptance criteria - -| Criterion | Result | Evidence | -|---|---|---| -| | pass/fail/not verified | command/file | - -## Tests and validation - -| Command | Exit code | Contract or failure protected | -|---|---:|---| - -## Tests intentionally not added - -Explain why broader unit, snapshot, CLI-help, local-file, or live tests would be redundant or out of scope. - -## Migration and compatibility notes - -State migration behavior, rollback/recovery behavior, and intentionally unsupported legacy paths. - -## Unresolved issues or source contradictions - -Do not hide uncertainties. - -## No-drift declaration - -- [ ] No files outside writable scope changed. -- [ ] No superfluous tests were added. -- [ ] No live target collection was performed. -- [ ] No compatibility layer was added outside the ticket. -- [ ] Documentation claims were verified against current source before implementation. diff --git a/prompt-packs/workflow-foundations-next/templates/source-baseline.example.json b/prompt-packs/workflow-foundations-next/templates/source-baseline.example.json deleted file mode 100644 index 3fcb45a..0000000 --- a/prompt-packs/workflow-foundations-next/templates/source-baseline.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "schema": "agent-workflow/source-baseline/v1", - "generated_at": "", - "components": { - "primary": { - "path": "", - "head": "", - "branch": "", - "dirty": false - } - } -} diff --git a/pyproject.toml b/pyproject.toml index d20e7cb..beba040 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,18 +4,22 @@ build-backend = "setuptools.build_meta" [project] name = "agent-workflow" -version = "0.2.1" +version = "0.2.2" description = "Terminal-first, observable workflow for bounded coding-agent delegation" readme = "README.md" requires-python = ">=3.11" dependencies = ["jsonschema>=4.18,<5"] classifiers = [ + "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: POSIX", ] [project.optional-dependencies] +dev = ["pytest>=8,<10"] eval = [ "inspect-ai==0.3.247", "inspect-swe==0.2.66", @@ -50,3 +54,8 @@ agent_workflow = ["assets/**/*"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] +addopts = "-ra" +markers = [ + "live: opt-in compatibility checks requiring real tmux or paid agent executors", + "future: strict expected-failure acceptance specifications for approved backlog behavior", +] diff --git a/schemas/evaluation-runtime.schema.json b/schemas/evaluation-runtime.schema.json new file mode 100644 index 0000000..9500d32 --- /dev/null +++ b/schemas/evaluation-runtime.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-workflow/evaluation-runtime/v1", + "type": "object", + "required": [ + "schema", + "evaluation_path", + "evaluation_sha256", + "timeout_seconds", + "acceptance_commands", + "scope", + "scorers", + "oracle_refs", + "statistics_policy", + "ticket_id", + "native_job_binding_sha256" + ], + "properties": { + "schema": {"const": "agent-workflow/evaluation-runtime/v1"}, + "evaluation_path": {"type": ["string", "null"]}, + "evaluation_sha256": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{64}$" + }, + "timeout_seconds": {"type": ["integer", "null"], "minimum": 1}, + "acceptance_commands": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "argv"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "argv": { + "type": "array", + "minItems": 1, + "items": {"type": "string"} + }, + "cwd": {"type": "string"}, + "timeout_seconds": {"type": "integer", "minimum": 1}, + "result_format": {"enum": ["exit-code", "junit"]}, + "junit_path": {"type": ["string", "null"]} + }, + "additionalProperties": false + } + }, + "scope": { + "type": "object", + "properties": { + "writable_paths": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string"} + }, + "writable_trees": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string"} + }, + "disposable_trees": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string"} + } + }, + "additionalProperties": false + }, + "scorers": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "minLength": 1} + }, + "oracle_refs": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["id", "sha256"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + }, + "additionalProperties": false + } + }, + "statistics_policy": {"type": "string", "minLength": 1}, + "ticket_id": {"type": ["string", "null"]}, + "native_job_binding_sha256": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{64}$" + } + }, + "additionalProperties": false +} diff --git a/scripts/audit-release-assets.py b/scripts/audit-release-assets.py index 43e72b6..0bcddbe 100755 --- a/scripts/audit-release-assets.py +++ b/scripts/audit-release-assets.py @@ -201,6 +201,10 @@ def main(argv: list[str] | None = None) -> int: ROOT / "src/agent_workflow/__init__.py": f'__version__ = "{EXPECTED_VERSION}"', ROOT / "src/agent_workflow/cli.py": f'%(prog)s {EXPECTED_VERSION}', ROOT / "src/agent_workflow/doctor.py": f'"version": "{EXPECTED_VERSION}"', + ROOT / "docs/man/agent-workflow.1": f"agent-workflow {EXPECTED_VERSION}", + ROOT / "docs/man/agent-workflow-workflow.1": f"agent-workflow {EXPECTED_VERSION}", + ROOT / "docs/man/agent-workflow-mcp.1": f"agent-workflow {EXPECTED_VERSION}", + ROOT / "docs/diagrams/REPOSITORY_CHART_PACK.md": f"**Release:** {EXPECTED_VERSION}", } for path, needle in version_locations.items(): if needle not in path.read_text(encoding="utf-8"): diff --git a/scripts/release-check.sh b/scripts/release-check.sh index 5a20ac5..c031b77 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -19,7 +19,7 @@ bash -n install.sh uninstall.sh bin/agent-workflow scripts/*.sh while IFS= read -r -d '' path; do bash -n "$path" done < <(find templates src/agent_workflow/assets -type f -name '*.sh' -print0) -python3 -m unittest discover -s tests -v +python3 -m pytest -q python3 -m agent_workflow pack validate examples/three-phase-pack python3 - <<'PY' import json diff --git a/skills/agent-workflow-orchestrator/SKILL.md b/skills/agent-workflow-orchestrator/SKILL.md index a3f204d..6a6b0bd 100644 --- a/skills/agent-workflow-orchestrator/SKILL.md +++ b/skills/agent-workflow-orchestrator/SKILL.md @@ -17,10 +17,8 @@ Use this skill when deciding whether work needs a durable delegation or when ope | User-visible child panes are required while the orchestrator is inside tmux | Invoke `agent-workflow launch`; never hand-create the tmux session or pane. | Select an agent class explicitly when the task differs from the configured -default. `exploratory` and `review` are normally non-interactive assignments; -they use an interactive executor in a private tmux session by default so they -can report completion and close themselves, while `implementation` is normally -an interactive pane. The application validates +default. `exploratory` and `review` are normally non-interactive detached runs; +`implementation` is normally an interactive pane. The application validates the class against configured executor/model pairs and rejects no-go models unless the caller supplies the explicit authorization flag. | A running agent needs new guidance | Append `steer`; treat it as pending until a correlated executor acknowledgement exists. | @@ -39,14 +37,7 @@ agent-workflow status project-p0-01 --capture 50 agent-workflow watch project-p0-01 --after 0 --timeout 300 ``` -When launched from a valid current tmux window, `agent-workflow launch` creates -a visible pane in that window for interactive assignments. Non-interactive -assignments default to a private named tmux session and are not user-resumable; -that private session is a detached named session from the orchestrator's -window; -their launch prompt requires durable completion notification and clean exit. -Always use the CLI so source baselines, prompts, commands, logs, and receipts -are recorded. +When launched from a valid current tmux window, `agent-workflow launch` creates a visible pane in that window. Without a usable tmux context, it falls back to a detached named session. Always use the CLI so source baselines, prompts, commands, logs, and receipts are recorded. ## Control and recovery @@ -61,11 +52,6 @@ agent-workflow restart project-p0-01 `steer` persists a durable request. It does not prove that a one-shot executor consumed semantic input. Only a correlated acknowledgement from a supported executor adapter establishes delivery/application. -Do not use `restart` as a user-facing recovery path for a non-interactive -assignment. Inspect its durable status and have the calling agent launch a new -attempt when needed. Pane limits and layout are scoped to the exact tmux -window; another tmux window has its own independent capacity. - ## Review and acceptance ```bash @@ -79,8 +65,9 @@ Terminal text is operational context, not sealed proof. Inspect the authoritativ ## Canonical references - [`docs/COMMAND_REFERENCE.md`](../../docs/COMMAND_REFERENCE.md) -- [`docs/DELEGATION_LIFECYCLE.md`](../../docs/DELEGATION_LIFECYCLE.md) -- [`docs/PROMPT_PACK_STANDARD.md`](../../docs/PROMPT_PACK_STANDARD.md) +- [`docs/OPERATIONS.md`](../../docs/OPERATIONS.md) +- [`docs/PROMPT_PACKS.md`](../../docs/PROMPT_PACKS.md) +- [`docs/TESTING.md`](../../docs/TESTING.md) - [`EXECUTION_PROTOCOL.md`](../../EXECUTION_PROTOCOL.md) - [`DELEGATION_RUNBOOK.md`](../../DELEGATION_RUNBOOK.md) diff --git a/src/agent_workflow/__init__.py b/src/agent_workflow/__init__.py index a4e2f4f..fabc589 100644 --- a/src/agent_workflow/__init__.py +++ b/src/agent_workflow/__init__.py @@ -1,2 +1,2 @@ """agent-workflow package.""" -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/src/agent_workflow/cli.py b/src/agent_workflow/cli.py index de84112..560f5bc 100644 --- a/src/agent_workflow/cli.py +++ b/src/agent_workflow/cli.py @@ -86,7 +86,7 @@ def _print_table( def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="agent-workflow") - parser.add_argument("--version", action="version", version="%(prog)s 0.2.1") + parser.add_argument("--version", action="version", version="%(prog)s 0.2.2") parser.add_argument("--config", type=Path, help="override config.toml path") parser.add_argument( "--json", diff --git a/src/agent_workflow/doctor.py b/src/agent_workflow/doctor.py index bad522c..b3ed43f 100644 --- a/src/agent_workflow/doctor.py +++ b/src/agent_workflow/doctor.py @@ -83,7 +83,7 @@ def run_doctor(settings: Settings) -> dict[str, Any]: } return { "ok": all(checks.values()), - "version": "0.2.1", + "version": "0.2.2", "config_path": str(settings.config_path), "commands": commands, "executors": { diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..45d5da3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Acceptance-first tests for the installed agent-workflow product.""" diff --git a/tests/acceptance/test_cli_product_journeys.py b/tests/acceptance/test_cli_product_journeys.py new file mode 100644 index 0000000..72f121a --- /dev/null +++ b/tests/acceptance/test_cli_product_journeys.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import hashlib +import subprocess +from pathlib import Path + +from tests.conftest import InstalledProduct, REPO_ROOT, git_repo + + +def test_installed_cli_exposes_operable_public_surface( + installed_product: InstalledProduct, product_env: dict[str, str] +) -> None: + expected_version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + version = installed_product.run("--version", env=product_env, check=True) + assert version.stdout.strip() == f"agent-workflow {expected_version}" + + help_result = installed_product.run("--help", env=product_env, check=True) + for command in ("doctor", "launch", "workflow", "eval", "pack", "worktree"): + assert command in help_result.stdout + + doctor = installed_product.json("doctor", env=product_env) + assert doctor["version"] == expected_version + assert doctor["checks"]["required_commands_present"] is True + assert doctor["commands"]["tmux"].endswith("/tmux") + + config = installed_product.json("config", "show", env=product_env) + assert config["terminal"]["backend"] == "tmux" + assert Path(config["paths"]["state_root"]).is_absolute() + + +def test_prompt_pack_scaffold_validate_and_archive_round_trip_is_deterministic( + installed_product: InstalledProduct, product_env: dict[str, str], tmp_path: Path +) -> None: + pack = tmp_path / "release-pack" + scaffold = installed_product.json( + "pack", "scaffold", pack, "--phases", "2", "--name", "Release Pack", env=product_env + ) + assert scaffold["phases"] == 2 + assert (pack / "phase-0" / "task-manifest.yaml").is_file() + + validation = installed_product.json("pack", "validate", pack, env=product_env) + assert validation["ok"] is True + + first = tmp_path / "first.tar.zst" + second = tmp_path / "second.tar.zst" + installed_product.json("pack", "archive", pack, first, env=product_env) + installed_product.json("pack", "archive", pack, second, env=product_env) + assert hashlib.sha256(first.read_bytes()).digest() == hashlib.sha256(second.read_bytes()).digest() + subprocess.run(["zstd", "-t", "-q", str(first)], check=True) + + +def test_worktree_create_list_and_remove_uses_real_git( + installed_product: InstalledProduct, product_env: dict[str, str], tmp_path: Path +) -> None: + repo = tmp_path / "repo" + head = git_repo(repo) + destination = tmp_path / "ticket-worktree" + + created = installed_product.json( + "worktree", "create", repo, "TICKET-1", "HEAD", "--dest", destination, env=product_env + ) + assert created["base_revision"] == head + assert destination.is_dir() + + listed = installed_product.json("worktree", "list", repo, env=product_env) + assert any(Path(item["worktree"]) == destination for item in listed) + + removed = installed_product.json( + "worktree", "remove", repo, destination, "--delete-branch", env=product_env + ) + assert removed["branch_deleted"] is True + assert not destination.exists() + + +def test_source_installer_round_trip_preserves_user_owned_paths( + product_env: dict[str, str], tmp_path: Path +) -> None: + home = tmp_path / "install-home" + env = dict(product_env) + env.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + } + ) + + def run(script: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(REPO_ROOT / script), "--no-deps"] if script == "install.sh" else [str(REPO_ROOT / script)], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + timeout=60, + check=False, + ) + + first = run("install.sh") + assert first.returncode == 0, first.stdout + first.stderr + launcher = home / ".local" / "bin" / "agent-workflow" + assert launcher.resolve() == (REPO_ROOT / "bin" / "agent-workflow").resolve() + assert (home / ".config" / "agent-workflow" / "config.toml").is_file() + version = subprocess.run( + [str(launcher), "--version"], env=env, text=True, capture_output=True, timeout=30, check=False + ) + assert version.returncode == 0, version.stderr + + second = run("install.sh") + assert second.returncode == 0, second.stdout + second.stderr + + user_owned = home / ".codex" / "skills" / "agent-workflow-orchestrator" + user_owned.unlink() + user_owned.write_text("user-owned\n", encoding="utf-8") + removed = run("uninstall.sh") + assert removed.returncode == 0, removed.stdout + removed.stderr + assert not launcher.exists() + assert user_owned.read_text(encoding="utf-8") == "user-owned\n" + assert (home / ".config" / "agent-workflow" / "config.toml").is_file() + assert "preserved unrelated path" in removed.stderr + + +def test_public_errors_are_actionable_and_nonzero( + installed_product: InstalledProduct, product_env: dict[str, str], tmp_path: Path +) -> None: + missing_snapshot = installed_product.run( + "workflow", "validate", tmp_path / "missing.json", env=product_env + ) + assert missing_snapshot.returncode == 2 + assert "cannot read" in missing_snapshot.stderr.lower() + + mcp = subprocess.run( + [str(installed_product.mcp), "--repo-root", str(tmp_path)], + env=product_env, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + assert mcp.returncode == 2 + assert "agent-workflow[mcp]" in mcp.stderr diff --git a/tests/acceptance/test_delegation_journeys.py b/tests/acceptance/test_delegation_journeys.py new file mode 100644 index 0000000..97f35af --- /dev/null +++ b/tests/acceptance/test_delegation_journeys.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tests.conftest import InstalledProduct, fake_agent_path, git_repo, wait_for_status, write_config + + +def _run_dir(env: dict[str, str], session_id: str) -> Path: + return Path(env["XDG_STATE_HOME"]) / "agent-workflow" / "runs" / session_id + + +def test_external_executor_completes_with_sealed_user_visible_evidence( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + head = git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text("Inspect the repository and report completion.\n", encoding="utf-8") + + launched = installed_product.json( + "launch", + "success-run", + repo, + prompt, + "--tier", + "low", + "--no-interactive", + "--", + fake_agent_path, + env=product_env, + ) + assert launched["session_id"] == "success-run" + + status = wait_for_status(product_env, "success-run") + assert status["status"] == "completed" + run = _run_dir(product_env, "success-run") + assert (run / "final-receipt.json").stat().st_mode & 0o222 == 0 + assert json.loads((run / "completion.json").read_text())["head_revision"] == head + assert "Inspect the repository" in (repo / ".agent-workflow-handoff" / "success-run" / "prompt-seen.txt").read_text() + + review = installed_product.json( + "review", "success-run", "--actor", "reviewer", "--reason", "evidence checked", env=product_env + ) + assert review["disposition"] == "reviewed" + accepted = installed_product.json( + "accept", + "success-run", + "--actor", + "reviewer", + "--reason", + "meets acceptance criteria", + "--revision", + head, + env=product_env, + ) + assert accepted["disposition"] == "accepted" + + +def test_durable_messages_survive_process_boundaries_and_are_acknowledged( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text("Wait briefly for steering.\n", encoding="utf-8") + env = dict(product_env) + env["FAKE_AGENT_MODE"] = "slow" + env["FAKE_AGENT_DELAY"] = "1.5" + + installed_product.json( + "launch", "message-run", repo, prompt, "--tier", "low", "--no-interactive", "--", fake_agent_path, + env=env, + ) + steer = installed_product.json( + "steer", "message-run", "Check the release docs too.", "--actor", "orchestrator", env=env + ) + watched = installed_product.json( + "watch", "message-run", "--after", "0", "--timeout", "0.2", env=env + ) + assert watched[0]["message_id"] == steer["message_id"] + ack = installed_product.json( + "ack", "message-run", steer["message_id"], "Applied", "--actor", "agent", env=env + ) + assert ack["correlation_id"] == steer["message_id"] + wait_for_status(env, "message-run") + + replayed = installed_product.json( + "watch", "message-run", "--after", "0", "--timeout", "0", env=env + ) + assert [item["kind"] for item in replayed] == ["steer", "ack"] + + + +def test_interactive_agent_reuse_requires_completion_selection_and_correlated_acknowledgement( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "first-assignment.md" + prompt.write_text("Remain available for a second assignment.\n", encoding="utf-8") + env = dict(product_env) + env["FAKE_AGENT_MODE"] = "slow" + env["FAKE_AGENT_DELAY"] = "4.0" + + installed_product.json( + "launch", + "reuse-agent", + repo, + prompt, + "--ticket", + "REUSE-1", + "--tier", + "low", + "--agent-class", + "implementation", + "--interactive", + "--", + fake_agent_path, + env=env, + ) + initial = installed_product.json("agent", "context", "reuse-agent", env=env) + assert initial["state"] == "busy" + assert initial["interactive"] is True + + completed = installed_product.json( + "agent", + "task-complete", + "reuse-agent", + "--actor", + "acceptance-worker", + "--summary", + "First assignment complete", + "--tag", + "acceptance", + env=env, + ) + assert completed["state"] == "idle_reusable" + + candidates = installed_product.json( + "agent", + "candidates", + repo, + "--ticket", + "REUSE-1", + "--agent-class", + "implementation", + "--tag", + "acceptance", + env=env, + ) + selected = next(item for item in candidates if item["session_id"] == "reuse-agent") + assert selected["eligible"] is True + assert selected["auto_reuse_eligible"] is True + + second_prompt = tmp_path / "second-assignment.md" + second_prompt.write_text("Acknowledge this assignment before continuing.\n", encoding="utf-8") + requested = installed_product.json( + "agent", + "reuse", + "reuse-agent", + second_prompt, + "--actor", + "orchestrator", + "--ticket", + "REUSE-2", + env=env, + ) + assert requested["context"]["state"] == "reuse_pending" + correlation_id = requested["message"]["message_id"] + watched = installed_product.json( + "watch", "reuse-agent", "--after", "0", "--timeout", "0", env=env + ) + assert any( + item["kind"] == "steer" and item["message_id"] == correlation_id + for item in watched + ) + + installed_product.json( + "ack", + "reuse-agent", + correlation_id, + "Second assignment accepted", + "--actor", + "acceptance-worker", + env=env, + ) + acknowledged = installed_product.json("agent", "context", "reuse-agent", env=env) + assert acknowledged["state"] == "busy" + assert acknowledged["reuse_count"] == 1 + wait_for_status(env, "reuse-agent") + +def test_executor_failure_is_terminal_sealed_and_restartable( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text("Fail intentionally.\n", encoding="utf-8") + env = dict(product_env) + env["FAKE_AGENT_MODE"] = "fail" + + installed_product.json( + "launch", "failed-run", repo, prompt, "--tier", "low", "--no-interactive", "--", fake_agent_path, + env=env, + ) + failed = wait_for_status(env, "failed-run") + assert failed["status"] == "failed" + assert failed["exit_code"] == 7 + assert (_run_dir(env, "failed-run") / "final-receipt.json").is_file() + + restarted = installed_product.json("restart", "failed-run", "--new-session", "failed-run-retry", env=env) + assert restarted["retry_of"] == "failed-run" + retry = wait_for_status(env, "failed-run-retry") + assert retry["status"] == "failed" + + +def test_structured_provider_events_reach_normalized_sealed_evidence( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text("Emit structured evidence.\n", encoding="utf-8") + config = write_config(product_env, fake_agent=fake_agent_path) + env = dict(product_env) + env["FAKE_AGENT_MODE"] = "structured" + + installed_product.json( + "launch", + "structured-run", + repo, + prompt, + "--config", + config, + "--executor", + "codex", + "--structured", + "--no-interactive", + "--tier", + "low", + env=env, + ) + wait_for_status(env, "structured-run") + evidence = json.loads((_run_dir(env, "structured-run") / "provider-evidence.json").read_text()) + assert evidence["usage_complete"] is True + assert evidence["aggregate"]["input_tokens"] == 5 + assert evidence["aggregate"]["cached_input_tokens"] == 1 + assert evidence["aggregate"]["output_tokens"] == 3 diff --git a/tests/acceptance/test_evaluation_journeys.py b/tests/acceptance/test_evaluation_journeys.py new file mode 100644 index 0000000..1f33023 --- /dev/null +++ b/tests/acceptance/test_evaluation_journeys.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tests.conftest import ( + InstalledProduct, + fake_agent_path, + git_repo, + wait_for_status, + write_config, +) + + +def test_sealed_evaluation_runs_score_report_collect_and_compare_through_installed_cli( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "evaluation-task.md" + prompt.write_text("Inspect README.md without modifying the repository.\n", encoding="utf-8") + plan = tmp_path / "evaluation.json" + plan.write_text( + json.dumps( + { + "schema": "agent-workflow/evaluation-plan/v1", + "dataset_split": "development", + "task_ids": ["EVAL-1"], + "repetitions": 1, + "timeout_seconds": 60, + "max_retries": 0, + "scorers": ["schema_validity", "writable_scope"], + "scope": { + "writable_paths": [], + "writable_trees": [], + "disposable_trees": [".agent-workflow-handoff/", ".delegations/"], + }, + "sandbox": "docker", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + config = write_config(product_env, fake_agent=fake_agent_path) + env = dict(product_env) + env["FAKE_AGENT_MODE"] = "structured" + state_runs = Path(env["XDG_STATE_HOME"]) / "agent-workflow" / "runs" + + validated = installed_product.json("eval", "validate", plan, env=env) + assert validated["task_ids"] == ["EVAL-1"] + + evidence_files: list[Path] = [] + for session_id in ("baseline-eval", "candidate-eval"): + installed_product.json( + "launch", + session_id, + repo, + prompt, + "--ticket", + "EVAL-1", + "--tier", + "low", + "--evaluation", + plan, + "--config", + config, + "--executor", + "codex", + "--structured", + "--no-interactive", + env=env, + ) + status = wait_for_status(env, session_id) + assert status["status"] == "completed" + run_dir = state_runs / session_id + + score = installed_product.json("eval", "score", run_dir, env=env) + assert score["verdict"] == "pass" + assert {item["scorer"]["id"] for item in score["scores"]} >= { + "schema_validity", + "writable_scope", + } + + report = tmp_path / f"{session_id}-report.md" + rendered = installed_product.json( + "eval", "report", run_dir, "--format", "markdown", "--output", report, env=env + ) + assert rendered["output"] == str(report) + assert "Overall deterministic verdict: `pass`" in report.read_text(encoding="utf-8") + + evidence = tmp_path / f"{session_id}-evidence.json" + collected = installed_product.json("eval", "collect", run_dir, "--output", evidence, env=env) + assert collected["trials"] == 1 + trial = json.loads(evidence.read_text(encoding="utf-8"))["trials"][0] + assert trial["task_id"] == "EVAL-1" + assert trial["verdict"] == "pass" + assert trial["input_tokens"] == 5 + assert trial["output_tokens"] == 3 + evidence_files.append(evidence) + + comparison_path = tmp_path / "comparison.json" + comparison = installed_product.json( + "eval", + "compare", + evidence_files[0], + evidence_files[1], + "--output", + comparison_path, + env=env, + ) + assert comparison["paired_n"] == 1 + assert comparison["winner"] is None + assert comparison["baseline"]["rate"] == 1.0 + assert comparison["candidate"]["rate"] == 1.0 + assert json.loads(comparison_path.read_text(encoding="utf-8"))["schema"] == "agent-workflow/comparison/v1" diff --git a/tests/acceptance/test_workflow_journeys.py b/tests/acceptance/test_workflow_journeys.py new file mode 100644 index 0000000..a9b2050 --- /dev/null +++ b/tests/acceptance/test_workflow_journeys.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import InstalledProduct, fake_agent_path, git_repo, wait_for_status, write_config + + +def _node(node_id: str, session_id: str, prompt: Path, dependencies: list[str]) -> dict: + return { + "node_id": node_id, + "kind": "task", + "ticket_id": node_id.upper(), + "session_id": session_id, + "tier": "low", + "pack_id": "acceptance-pack", + "prompt_path": str(prompt), + "agent_class": "review", + "executor": "codex", + "model": "gpt-5.4-mini", + "interactive": False, + "allow_no_go_model": False, + "routing": {"task_type": "review", "risk": "low"}, + "input_bindings": {}, + "dependencies": dependencies, + } + + +def _snapshot(workflow_id: str, nodes: list[dict]) -> dict: + return { + "schema": "agent-workflow/workflow-snapshot/v1", + "workflow_id": workflow_id, + "pack_id": "acceptance-pack", + "pack_manifest_sha256": "0" * 64, + "nodes": nodes, + } + + +def _write(path: Path, value: dict) -> Path: + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + return path + + +def test_pipeline_runs_through_installed_cli_binds_results_and_seals_terminal_workflow( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + run_dir = tmp_path / "workflow-run" + git_repo(run_dir) + pack = run_dir / "pack" + tickets = pack / "phase-0" / "tickets" + contracts = pack / "contracts" + tickets.mkdir(parents=True) + contracts.mkdir(parents=True) + first_prompt = tickets / "first.md" + second_prompt = tickets / "second.md" + first_prompt.write_text("Produce the first structured result.\n", encoding="utf-8") + second_prompt.write_text("Consume the sealed predecessor input.\n", encoding="utf-8") + (pack / "pack.yaml").write_text( + 'schema: agent-workflow/pack/v1\npack_id: "acceptance-pack"\n', encoding="utf-8" + ) + (pack / "phase-0" / "task-manifest.yaml").write_text( + """phase: "0" +name: "acceptance" +tasks: + - id: "FIRST" + tier: C + session: "pipeline-first" + prompt: "tickets/first.md" + result_contract: + schema: "contracts/result.schema.json" + required: true + - id: "SECOND" + tier: C + session: "pipeline-second" + prompt: "tickets/second.md" + dependencies: ["FIRST"] +""", + encoding="utf-8", + ) + (contracts / "result.schema.json").write_text( + json.dumps( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["artifact"], + "properties": { + "artifact": { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}}, + "additionalProperties": False, + } + }, + "additionalProperties": False, + } + ), + encoding="utf-8", + ) + subprocess.run(["git", "-C", str(run_dir), "add", "."], check=True) + subprocess.run(["git", "-C", str(run_dir), "commit", "-qm", "workflow pack"], check=True) + config = write_config(product_env, fake_agent=fake_agent_path) + second = _node("second", "pipeline-second", second_prompt, ["first"]) + second["input_bindings"] = { + "upstream_id": { + "source_node_id": "first", + "pointer": "/artifact/id", + "required": True, + "max_bytes": 1024, + } + } + snapshot = _write( + tmp_path / "workflow.json", + _snapshot( + "pipeline-e2e", + [_node("first", "pipeline-first", first_prompt, []), second], + ), + ) + env = dict(product_env) + env["FAKE_AGENT_RESULT_JSON"] = json.dumps({"artifact": {"id": "stage-one"}}) + + validated = installed_product.json("workflow", "validate", snapshot, "--config", config, env=env) + assert validated["result"]["node_count"] == 2 + started = installed_product.json("workflow", "start", run_dir, snapshot, "--config", config, env=env) + assert started["scheduled"] == ["first"] + wait_for_status(env, "pipeline-first") + + resumed = installed_product.json("workflow", "resume", run_dir, snapshot, "--config", config, env=env) + assert resumed["scheduled"] == ["second"] + wait_for_status(env, "pipeline-second") + + installed_product.json("workflow", "resume", run_dir, snapshot, "--config", config, env=env) + status = installed_product.json("workflow", "status", run_dir, snapshot, "--config", config, env=env) + assert status["result"]["workflow_state"] == "completed" + child_inputs = json.loads( + (Path(env["XDG_STATE_HOME"]) / "agent-workflow" / "runs" / "pipeline-second" / "workflow-inputs.json").read_text() + ) + binding = child_inputs["bindings"][0] + assert binding["name"] == "upstream_id" + assert binding["value"] == "stage-one" + + sealed = installed_product.json("workflow", "seal", run_dir, snapshot, "--config", config, env=env) + verified = installed_product.json("workflow", "verify", run_dir, snapshot, "--config", config, env=env) + assert sealed["result"]["verified"] is True + assert verified["result"]["receipt_sha256"] == sealed["result"]["receipt_sha256"] + assert (run_dir / "workflow-receipt.json").stat().st_mode & 0o222 == 0 + + +def test_approval_gate_requires_canonical_child_acceptance( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + run_dir = tmp_path / "approval-workflow" + revision = git_repo(run_dir) + prompt = run_dir / "implementation.md" + prompt.write_text("Implement the approved change.\n", encoding="utf-8") + subprocess.run(["git", "-C", str(run_dir), "add", "."], check=True) + subprocess.run(["git", "-C", str(run_dir), "commit", "-qm", "workflow prompt"], check=True) + revision = subprocess.run( + ["git", "-C", str(run_dir), "rev-parse", "HEAD"], text=True, capture_output=True, check=True + ).stdout.strip() + config = write_config(product_env, fake_agent=fake_agent_path) + task = _node("implementation", "approval-child", prompt, []) + approval = { + "node_id": "approval", + "kind": "approval", + "approval_for": "implementation", + "dependencies": ["implementation"], + } + snapshot = _write(tmp_path / "approval.json", _snapshot("approval-e2e", [task, approval])) + + installed_product.json("workflow", "start", run_dir, snapshot, "--config", config, env=product_env) + wait_for_status(product_env, "approval-child") + before = installed_product.json("workflow", "resume", run_dir, snapshot, "--config", config, env=product_env) + assert before["scheduled"] == [] + status = installed_product.json("workflow", "status", run_dir, snapshot, "--config", config, env=product_env) + approval_state = {item["node_id"]: item["state"] for item in status["result"]["nodes"]}["approval"] + assert approval_state == "eligible" + + installed_product.json( + "review", "approval-child", "--actor", "independent-reviewer", "--reason", "verified", env=product_env + ) + installed_product.json( + "accept", "approval-child", "--actor", "independent-reviewer", "--reason", "accepted", "--revision", revision, + env=product_env, + ) + installed_product.json("workflow", "resume", run_dir, snapshot, "--config", config, env=product_env) + terminal = installed_product.json("workflow", "status", run_dir, snapshot, "--config", config, env=product_env) + assert terminal["result"]["workflow_state"] == "completed" + + +def test_resume_is_idempotent_and_does_not_duplicate_child_launches( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + run_dir = tmp_path / "resume-workflow" + git_repo(run_dir) + prompt = run_dir / "task.md" + prompt.write_text("Run exactly once.\n", encoding="utf-8") + subprocess.run(["git", "-C", str(run_dir), "add", "."], check=True) + subprocess.run(["git", "-C", str(run_dir), "commit", "-qm", "prompt"], check=True) + config = write_config(product_env, fake_agent=fake_agent_path) + snapshot = _write( + tmp_path / "resume.json", + _snapshot("resume-e2e", [_node("only", "resume-child", prompt, [])]), + ) + + installed_product.json("workflow", "start", run_dir, snapshot, "--config", config, env=product_env) + wait_for_status(product_env, "resume-child") + first = installed_product.json("workflow", "resume", run_dir, snapshot, "--config", config, env=product_env) + reconciled = installed_product.json("workflow", "status", run_dir, snapshot, "--config", config, env=product_env) + second = installed_product.json("workflow", "resume", run_dir, snapshot, "--config", config, env=product_env) + after = installed_product.json("workflow", "status", run_dir, snapshot, "--config", config, env=product_env) + assert first["scheduled"] == [] + assert second["scheduled"] == [] + assert after["result"]["event_count"] == reconciled["result"]["event_count"] + only = next(item for item in after["result"]["nodes"] if item["node_id"] == "only") + assert only["attempt"] == 1 + + +@pytest.mark.parametrize( + ("template", "parameters", "expected_nodes"), + [ + ( + "pipeline", + {"steps": [{"node_id": "a", "session_id": "a", "prompt_path": "/tmp/a"}, {"node_id": "b", "session_id": "b", "prompt_path": "/tmp/b"}]}, + 2, + ), + ( + "parallel-review-fan-in", + { + "subject": {"node_id": "subject", "session_id": "subject", "prompt_path": "/tmp/subject"}, + "reviews": [ + {"node_id": "r1", "session_id": "r1", "prompt_path": "/tmp/r1"}, + {"node_id": "r2", "session_id": "r2", "prompt_path": "/tmp/r2"}, + ], + "fan_in": {"node_id": "merge", "session_id": "merge", "prompt_path": "/tmp/merge"}, + }, + 4, + ), + ( + "implementation-independent-review", + { + "implementation": {"node_id": "implementation", "session_id": "implementation", "prompt_path": "/tmp/implementation"}, + "review": {"node_id": "review", "session_id": "review", "prompt_path": "/tmp/review"}, + }, + 2, + ), + ], +) +def test_authorized_templates_expand_to_valid_executable_snapshots( + installed_product: InstalledProduct, + product_env: dict[str, str], + tmp_path: Path, + template: str, + parameters: dict, + expected_nodes: int, +) -> None: + spec = _write( + tmp_path / f"{template}-spec.json", + { + "workflow_id": f"{template}-workflow", + "pack_id": "acceptance-pack", + "pack_manifest_sha256": "0" * 64, + "parameters": parameters, + }, + ) + output = tmp_path / f"{template}.json" + installed_product.run("workflow", "template", template, spec, "--output", output, env=product_env, check=True) + validated = installed_product.json("workflow", "validate", output, env=product_env) + assert validated["result"]["node_count"] == expected_nodes diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d2f2b95 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +import venv +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest +import jsonschema + +REPO_ROOT = Path(__file__).resolve().parents[1] +TERMINAL_STATES = {"completed", "failed", "interrupted", "killed"} + + +@dataclass(frozen=True) +class InstalledProduct: + root: Path + python: Path + cli: Path + mcp: Path + + def run( + self, + *args: str | os.PathLike[str], + env: dict[str, str] | None = None, + cwd: Path | None = None, + check: bool = False, + timeout: float = 30, + ) -> subprocess.CompletedProcess[str]: + command = [str(self.cli), *(str(value) for value in args)] + result = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + if check and result.returncode: + raise AssertionError( + f"command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + def json( + self, + *args: str | os.PathLike[str], + env: dict[str, str] | None = None, + cwd: Path | None = None, + timeout: float = 30, + ) -> Any: + result = self.run("--json", *args, env=env, cwd=cwd, check=True, timeout=timeout) + return json.loads(result.stdout) + + +@pytest.fixture(scope="session") +def installed_product(tmp_path_factory: pytest.TempPathFactory) -> InstalledProduct: + root = tmp_path_factory.mktemp("installed-product") + source = root / "source" + shutil.copytree( + REPO_ROOT, + source, + ignore=shutil.ignore_patterns( + ".git", ".pytest_cache", "__pycache__", "*.pyc", "build", "dist", "*.egg-info" + ), + ) + wheelhouse = root / "wheelhouse" + wheelhouse.mkdir() + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-deps", + "--no-build-isolation", + "--wheel-dir", + str(wheelhouse), + str(source), + ], + text=True, + capture_output=True, + check=True, + timeout=120, + ) + wheel = next(wheelhouse.glob("agent_workflow-*.whl")) + environment = root / "venv" + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment) + python = environment / "bin" / "python" + subprocess.run( + [str(python), "-m", "pip", "install", "--no-deps", str(wheel)], + text=True, + capture_output=True, + check=True, + timeout=120, + ) + return InstalledProduct( + root=root, + python=python, + cli=environment / "bin" / "agent-workflow", + mcp=environment / "bin" / "agent-workflow-mcp", + ) + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +@pytest.fixture +def product_env(tmp_path: Path, installed_product: InstalledProduct) -> dict[str, str]: + home = tmp_path / "home" + fake_bin = tmp_path / "bin" + tmux_state = tmp_path / "fake-tmux" + for path in (home, fake_bin, tmux_state): + path.mkdir(parents=True) + + _write_executable( + fake_bin / "tmux", + r'''#!/usr/bin/env python3 +import json +import os +import signal +import subprocess +import sys +from pathlib import Path + +root = Path(os.environ["FAKE_TMUX_STATE"]) +root.mkdir(parents=True, exist_ok=True) +args = sys.argv[1:] +command = args[0] if args else "" + +def value(flag, default=None): + try: + return args[args.index(flag) + 1] + except (ValueError, IndexError): + return default + +def marker(session): + return root / f"{session}.json" + +if command in {"set-option", "select-pane", "send-keys", "wait-for"}: + raise SystemExit(0) +if command == "has-session": + raise SystemExit(0 if marker(value("-t", "")).exists() else 1) +if command == "new-session": + session = value("-s") + workdir = value("-c", os.getcwd()) + runner = args[-1] + process = subprocess.Popen( + [runner], cwd=workdir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + marker(session).write_text(json.dumps({"pid": process.pid, "runner": runner}), encoding="utf-8") + raise SystemExit(0) +if command == "list-panes": + session = value("-t", "") + data = json.loads(marker(session).read_text(encoding="utf-8")) if marker(session).exists() else {"pid": 0} + fmt = value("-F", "") + if "pane_pid" in fmt: + print(f"{data['pid']}\t0\tpython3") + elif "pane_left" in fmt: + print("%1\torchestrator\t0\t0\t0\t") + elif "pane_id" in fmt: + print("%1\torchestrator\t0") + raise SystemExit(0) +if command == "kill-session": + session = value("-t", "") + path = marker(session) + if path.exists(): + data = json.loads(path.read_text(encoding="utf-8")) + try: + os.killpg(int(data["pid"]), signal.SIGTERM) + except (ProcessLookupError, PermissionError, ValueError): + pass + path.unlink(missing_ok=True) + raise SystemExit(0) +if command == "capture-pane": + raise SystemExit(0) +if command == "display-message": + print("fake:0") + raise SystemExit(0) +if command == "split-window": + print("fake:0.1") + raise SystemExit(0) +raise SystemExit(0) +''', + ) + + _write_executable( + fake_bin / "fake-agent", + r'''#!/usr/bin/env python3 +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +mode = os.environ.get("FAKE_AGENT_MODE", "success") +prompt = sys.stdin.read() +handoff = Path(os.environ["AGENT_WORKFLOW_HANDOFF_DIR"]) +handoff.mkdir(parents=True, exist_ok=True) +(handoff / "prompt-seen.txt").write_text(prompt, encoding="utf-8") +if mode == "slow": + time.sleep(float(os.environ.get("FAKE_AGENT_DELAY", "1.0"))) +try: + head = subprocess.run( + ["git", "rev-parse", "HEAD"], text=True, capture_output=True, check=True + ).stdout.strip() +except subprocess.CalledProcessError: + head = None +result = "failed" if mode == "fail" else "completed" +completion = { + "schema": "agent-workflow/completion/v1", + "session_id": os.environ["AGENT_WORKFLOW_SESSION_ID"], + "ticket_id": None, + "pack_id": None, + "result": result, + "base_revision": head, + "head_revision": head, + "changed_files": [], + "criteria": [], + "commands": [], + "unresolved": ["intentional failure"] if mode == "fail" else [], + "usage": None, +} +(handoff / "completion.json").write_text(json.dumps(completion), encoding="utf-8") +result_json = os.environ.get("FAKE_AGENT_RESULT_JSON") +if result_json: + (handoff / "result.json").write_text(result_json, encoding="utf-8") +if mode == "structured": + print(json.dumps({"event_id": "message-1", "type": "item.completed", "item": {"type": "agent_message", "text": "fake agent completed"}})) + print(json.dumps({"event_id": "usage-1", "type": "turn.completed", "usage": {"input_tokens": 5, "cached_input_tokens": 1, "output_tokens": 3}})) +else: + print("fake agent completed") +if mode == "fail": + print("intentional executor failure", file=sys.stderr) + raise SystemExit(7) +''', + ) + + environment = os.environ.copy() + environment.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(tmp_path / "config"), + "XDG_DATA_HOME": str(tmp_path / "data"), + "XDG_STATE_HOME": str(tmp_path / "state"), + "FAKE_TMUX_STATE": str(tmux_state), + "PATH": os.pathsep.join( + [str(fake_bin), str(installed_product.cli.parent), environment.get("PATH", "")] + ), + "PYTHONUNBUFFERED": "1", + "PYTHONPATH": str(Path(jsonschema.__file__).resolve().parents[1]), + } + ) + return environment + + +def git_repo(path: Path) -> str: + path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "-q", str(path)], check=True) + subprocess.run(["git", "-C", str(path), "config", "user.email", "tests@example.invalid"], check=True) + subprocess.run(["git", "-C", str(path), "config", "user.name", "Acceptance Tests"], check=True) + (path / "README.md").write_text("fixture\n", encoding="utf-8") + subprocess.run(["git", "-C", str(path), "add", "README.md"], check=True) + subprocess.run(["git", "-C", str(path), "commit", "-qm", "fixture"], check=True) + return subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + + +def wait_for_status(env: dict[str, str], session_id: str, *, timeout: float = 20) -> dict[str, Any]: + status_path = Path(env["XDG_STATE_HOME"]) / "agent-workflow" / "runs" / session_id / "status.json" + deadline = time.monotonic() + timeout + last: dict[str, Any] | None = None + while time.monotonic() < deadline: + if status_path.is_file(): + try: + last = json.loads(status_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + last = None + if last and last.get("status") in TERMINAL_STATES: + return last + time.sleep(0.05) + raise AssertionError(f"session did not reach a terminal state: {session_id}; last={last}") + + +def write_config(env: dict[str, str], *, fake_agent: Path, structured_executor: bool = False) -> Path: + path = Path(env["XDG_CONFIG_HOME"]) / "agent-workflow" / "config.toml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join( + [ + "[executors.codex]", + f'command = ["{fake_agent}"]', + 'models = ["gpt-5.4-mini"]', + 'default_model = "gpt-5.4-mini"', + 'model_arg = ["--model"]', + 'interactive_permission_args = []', + 'non_interactive_permission_args = []', + "", + "[git]", + "require_clean_source = false", + "", + "[agents]", + 'default_executor = "codex"', + 'default_class = "review"', + ] + ) + + "\n", + encoding="utf-8", + ) + return path + + +@pytest.fixture +def fake_agent_path(product_env: dict[str, str]) -> Path: + return Path(product_env["PATH"].split(os.pathsep)[0]) / "fake-agent" diff --git a/tests/fixtures/native-jobs/valid-job.json b/tests/fixtures/native-jobs/valid-job.json deleted file mode 100644 index a1743db..0000000 --- a/tests/fixtures/native-jobs/valid-job.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "schema": "agent-workflow/native-job/v1", - "job_id": "native-job-1", - "ticket_id": "P0-01", - "prompt_path": "prompts/P0-01.md", - "worktree_target": "worktrees/P0-01", - "path_policy": { - "allowed_paths": ["src/example.py", "tests/test_example.py"], - "forbidden_paths": ["secrets"] - }, - "acceptance_commands": [ - {"id": "unit", "argv": ["python3", "-m", "pytest", "-q"]} - ], - "review_requirement": {"required": true, "independent": true} -} diff --git a/tests/fixtures/regression-evals/deterministic-json/app/__init__.py b/tests/fixtures/regression-evals/deterministic-json/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/regression-evals/deterministic-json/app/normalize.py b/tests/fixtures/regression-evals/deterministic-json/app/normalize.py deleted file mode 100644 index 43cd302..0000000 --- a/tests/fixtures/regression-evals/deterministic-json/app/normalize.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Deterministic normalization target used by the receipt-backed regression eval.""" -from __future__ import annotations - -from typing import Any, Iterable - - -def normalize_records(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: - """Return unique records normalized by case-folded id and stable key order. - - The last record for a normalized id wins. Input objects are never mutated. - """ - normalized: dict[str, dict[str, Any]] = {} - for record in records: - if not isinstance(record, dict): - raise TypeError("each record must be an object") - raw_id = record.get("id") - if not isinstance(raw_id, str) or not raw_id.strip(): - raise ValueError("record id must be a non-empty string") - key = raw_id.strip().casefold() - clean = {str(name): value for name, value in record.items()} - clean["id"] = key - normalized[key] = dict(sorted(clean.items())) - return [normalized[key] for key in sorted(normalized)] diff --git a/tests/fixtures/regression-evals/deterministic-json/evaluation.json b/tests/fixtures/regression-evals/deterministic-json/evaluation.json deleted file mode 100644 index 7709738..0000000 --- a/tests/fixtures/regression-evals/deterministic-json/evaluation.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "schema": "agent-workflow/evaluation-plan/v1", - "dataset_split": "development", - "task_ids": [ - "normalize-records" - ], - "repetitions": 2, - "timeout_seconds": 60, - "scorers": [ - "acceptance_commands", - "regression_guard", - "writable_scope", - "oracle_leak" - ], - "sandbox": "local-offline", - "acceptance_commands": [ - { - "id": "known-tests", - "argv": [ - "python3", - "-m", - "pytest", - "-q", - "tests/test_normalize.py" - ] - } - ], - "scope": { - "writable_paths": [ - "app/normalize.py" - ] - }, - "oracle_refs": { - "normalize-records": { - "id": "normalize-records-hidden-v1", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000" - } - } -} diff --git a/tests/fixtures/regression-evals/deterministic-json/mutations/first_wins.py b/tests/fixtures/regression-evals/deterministic-json/mutations/first_wins.py deleted file mode 100644 index d25ed22..0000000 --- a/tests/fixtures/regression-evals/deterministic-json/mutations/first_wins.py +++ /dev/null @@ -1,6 +0,0 @@ -def normalize_records(records): - values = {} - for record in records: - key = record["id"].strip().casefold() - values.setdefault(key, {**record, "id": key}) - return [values[key] for key in sorted(values)] diff --git a/tests/fixtures/regression-evals/deterministic-json/mutations/mutates_input.py b/tests/fixtures/regression-evals/deterministic-json/mutations/mutates_input.py deleted file mode 100644 index d636ed2..0000000 --- a/tests/fixtures/regression-evals/deterministic-json/mutations/mutates_input.py +++ /dev/null @@ -1,4 +0,0 @@ -def normalize_records(records): - for record in records: - record["id"] = record["id"].strip().casefold() - return sorted(records, key=lambda item: item["id"]) diff --git a/tests/fixtures/regression-evals/deterministic-json/mutations/no_dedup.py b/tests/fixtures/regression-evals/deterministic-json/mutations/no_dedup.py deleted file mode 100644 index eb5396f..0000000 --- a/tests/fixtures/regression-evals/deterministic-json/mutations/no_dedup.py +++ /dev/null @@ -1,2 +0,0 @@ -def normalize_records(records): - return [{**record, "id": record["id"].strip().casefold()} for record in records] diff --git a/tests/fixtures/regression-evals/deterministic-json/tests/test_normalize.py b/tests/fixtures/regression-evals/deterministic-json/tests/test_normalize.py deleted file mode 100644 index e5cfab0..0000000 --- a/tests/fixtures/regression-evals/deterministic-json/tests/test_normalize.py +++ /dev/null @@ -1,21 +0,0 @@ -from copy import deepcopy -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from app.normalize import normalize_records - - -def test_normalizes_sorts_and_deduplicates_without_mutation(): - source = [{"id": " B ", "value": 1}, {"value": 2, "id": "a"}, {"id": "b", "value": 3}] - before = deepcopy(source) - assert normalize_records(source) == [{"id": "a", "value": 2}, {"id": "b", "value": 3}] - assert source == before - - -def test_rejects_missing_id(): - try: - normalize_records([{"value": 1}]) - except ValueError: - pass - else: - raise AssertionError("missing id must fail") diff --git a/tests/fixtures/regression-evals/priority-picker/README.md b/tests/fixtures/regression-evals/priority-picker/README.md deleted file mode 100644 index c421de7..0000000 --- a/tests/fixtures/regression-evals/priority-picker/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Priority-picker visual regression fixture — blocked gate - -> Status: active blocked-test contract linked from BKL-010; this is not a completed fixture claim. - - -The Phase 3 fixture is intentionally not represented as complete in this source archive. -The ticket requires a pinned offline browser image, pinned fonts, deterministic screenshot -comparison, and imported browser/Inspect artifacts before sealing. This environment does -not contain an authoritative browser image digest or a verified Inspect/browser evidence -bridge. Implementing an unpinned browser test would violate the ticket's stop condition. - -The required protocol contract is frozen here for the follow-on implementation: - -1. `child_started` -2. `child_request` -3. `child_result` -4. `child_finished` - -All four records must be explicit, ordered, session-bound, and sealed. DOM and ARIA -assertions are primary; screenshots are secondary with a declared tolerance. Negative -fixtures must independently reject missing child telemetry, late/invalid results, ignored -results, broken Escape behavior, visual mismatch, and scope escape. diff --git a/tests/future/README.md b/tests/future/README.md new file mode 100644 index 0000000..3e7f944 --- /dev/null +++ b/tests/future/README.md @@ -0,0 +1,9 @@ +# Future acceptance specifications + +These are black-box journeys for backlog behavior that is intentionally not implemented yet. +They run against the installed wheel and are marked `xfail(strict=True)`: the expected failure +keeps the specification visible, while an unexpected pass fails the suite until the scenario is +reviewed and promoted into `tests/acceptance/`. + +Do not add parser, dictionary-shape, or mocked-helper expectations here. A future test must name +an approved backlog item and exercise a complete operator-visible outcome. diff --git a/tests/future/test_late_steering_journey.py b/tests/future/test_late_steering_journey.py new file mode 100644 index 0000000..5786871 --- /dev/null +++ b/tests/future/test_late_steering_journey.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.conftest import InstalledProduct, fake_agent_path, git_repo, wait_for_status + + +@pytest.mark.future +@pytest.mark.xfail( + strict=True, + reason="BKL-002: detached executors do not yet consume and acknowledge post-launch steering", +) +def test_detached_executor_consumes_post_launch_steer_without_restart( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text("Remain active long enough to receive steering.\n", encoding="utf-8") + env = dict(product_env) + env["FAKE_AGENT_MODE"] = "slow" + env["FAKE_AGENT_DELAY"] = "2.0" + + installed_product.json( + "launch", + "late-steer-future", + repo, + prompt, + "--tier", + "low", + "--no-interactive", + "--", + fake_agent_path, + env=env, + ) + steer = installed_product.json( + "steer", + "late-steer-future", + "Report progress before continuing.", + "--actor", + "orchestrator", + env=env, + ) + try: + messages = installed_product.json( + "watch", "late-steer-future", "--after", "0", "--timeout", "1.0", env=env + ) + assert any( + item.get("kind") == "ack" and item.get("correlation_id") == steer["message_id"] + for item in messages + ) + finally: + wait_for_status(env, "late-steer-future") diff --git a/tests/invariants/test_durable_state.py b/tests/invariants/test_durable_state.py new file mode 100644 index 0000000..1058fb6 --- /dev/null +++ b/tests/invariants/test_durable_state.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agent_workflow.config import defaults +from agent_workflow.errors import WorkflowError +from agent_workflow.messages import append_message, replay_messages +from agent_workflow.routing import advise_routing +from agent_workflow.scheduler import calculate_eligibility, plan_launches + + +def test_message_log_is_contiguous_replayable_and_fail_closed(tmp_path: Path) -> None: + records = [ + append_message(tmp_path, session_id="run", direction="parent_to_child", kind="steer", actor="parent", content="inspect"), + append_message(tmp_path, session_id="run", direction="child_to_parent", kind="progress", actor="child", content="working"), + ] + assert [item["sequence"] for item in records] == [1, 2] + assert replay_messages(tmp_path, after_sequence=1) == [records[1]] + + path = tmp_path / "messages.jsonl" + path.write_text(path.read_text() + "not-json\n", encoding="utf-8") + with pytest.raises(WorkflowError): + replay_messages(tmp_path) + + +def test_scheduler_capacity_and_dependency_release_are_graph_invariants() -> None: + snapshot = { + "nodes": [ + {"node_id": "a", "kind": "task", "dependencies": []}, + {"node_id": "b", "kind": "task", "dependencies": ["a"]}, + {"node_id": "c", "kind": "task", "dependencies": []}, + ] + } + status = { + "nodes": [ + {"node_id": "a", "state": "completed"}, + {"node_id": "b", "state": "eligible"}, + {"node_id": "c", "state": "running"}, + ] + } + assert calculate_eligibility(snapshot, status) == ["b"] + assert plan_launches(snapshot, status, max_parallelism=1) == [] + assert plan_launches(snapshot, status, max_parallelism=2) == ["b"] + + +def test_routing_is_deterministic_advisory_and_cannot_override_enforced_policy() -> None: + metadata = {"task_type": "review", "risk": "high", "interactive": True} + enforced = {"agent_class": "implementation", "executor": "codex", "model": "gpt-5.4-mini", "interactive": False} + settings = defaults() + first = advise_routing(metadata, settings, enforced_selection=enforced) + second = advise_routing(dict(metadata), settings, enforced_selection=dict(enforced)) + assert first == second + recommendation = first["recommendation"] + assert recommendation["agent_class"] in settings.agent_classes + assert recommendation["executor"] in settings.executors + assert first["enforced_selection"] == enforced + assert first["policy_disagreements"] + + +def test_invalid_scheduler_parallelism_is_rejected() -> None: + with pytest.raises(WorkflowError): + plan_launches({"nodes": []}, {"nodes": []}, max_parallelism=0) diff --git a/tests/invariants/test_provider_and_evaluation.py b/tests/invariants/test_provider_and_evaluation.py new file mode 100644 index 0000000..a97854e --- /dev/null +++ b/tests/invariants/test_provider_and_evaluation.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from agent_workflow.errors import WorkflowError +from agent_workflow.eval.compare import compare_trials +from agent_workflow.provider_evidence import build_provider_evidence +from tests.support import trial + + +def _evidence(tmp_path: Path, events: list[dict], stream_format: str = "codex-jsonl") -> dict: + path = tmp_path / "executor-events.jsonl" + path.write_text("".join(json.dumps(event) + "\n" for event in events), encoding="utf-8") + return build_provider_evidence( + events_path=path, + stream_format=stream_format, + executor="codex" if stream_format == "codex-jsonl" else "claude", + session_id="provider-matrix", + ) + + +@pytest.mark.parametrize( + ("events", "complete", "input_tokens", "reason"), + [ + ([{"event_id": "1", "usage_mode": "delta", "usage": {"input_tokens": 2}}, {"event_id": "2", "usage_mode": "delta", "usage": {"input_tokens": 3}}], True, 5, None), + ([{"event_id": "same", "usage_mode": "delta", "usage": {"input_tokens": 2}}, {"event_id": "same", "usage_mode": "delta", "usage": {"input_tokens": 9}}], False, 2, "CONFLICTING_PROVIDER_EVENT_ID"), + ([{"usage_mode": "delta", "usage": {"input_tokens": 2}}, {"usage_mode": "cumulative", "usage": {"input_tokens": 2}}], False, None, "MIXED_NONTERMINAL_USAGE_MODES"), + ([{"type": "turn.completed"}], False, None, "TERMINAL_USAGE_EMPTY"), + ], +) +def test_provider_usage_accounting_matrix( + tmp_path: Path, events: list[dict], complete: bool, input_tokens: int | None, reason: str | None +) -> None: + evidence = _evidence(tmp_path, events) + assert evidence["usage_complete"] is complete + assert evidence["aggregate"]["input_tokens"] == input_tokens + if reason: + assert reason in evidence["incomplete_reasons"] + + +def test_terminal_totals_are_authoritative_without_double_counting(tmp_path: Path) -> None: + evidence = _evidence( + tmp_path, + [ + {"event_id": "d1", "usage_mode": "delta", "usage": {"input_tokens": 3, "output_tokens": 2}}, + {"event_id": "d2", "usage_mode": "delta", "usage": {"input_tokens": 4, "output_tokens": 5}}, + {"event_id": "t", "type": "turn.completed", "usage": {"input_tokens": 7, "output_tokens": 7}}, + ], + ) + assert evidence["aggregate"]["input_tokens"] == 7 + assert evidence["aggregate"]["output_tokens"] == 7 + + +def test_comparison_rejects_mismatched_cohorts_and_never_overclaims_small_samples() -> None: + result = compare_trials([trial("b", "fail")], [trial("c", "pass")]) + assert result["winner"] is None + assert result["paired_n"] == 1 + with pytest.raises(WorkflowError, match="cohorts do not match"): + compare_trials([trial("b", "fail", task_id="a")], [trial("c", "pass", task_id="b")]) diff --git a/tests/invariants/test_security_boundaries.py b/tests/invariants/test_security_boundaries.py new file mode 100644 index 0000000..d296ad6 --- /dev/null +++ b/tests/invariants/test_security_boundaries.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from agent_workflow.errors import WorkflowError +from agent_workflow.receipts import make_read_only, seal_run, verify_seal +from agent_workflow.util import sha256_file +from tests.support import write_minimal_run + + +def test_sealed_receipt_rejects_tampering_and_path_redirection_matrix(tmp_path: Path) -> None: + cases = ("artifact", "receipt", "intermediate-symlink") + for case in cases: + root = tmp_path / case + write_minimal_run(root, session_id=case) + seal_run(root, session_id=case) + expected = sha256_file(root / "final-receipt.json") + if case == "artifact": + os.chmod(root / "output.log", 0o644) + (root / "output.log").write_text("forged\n", encoding="utf-8") + elif case == "receipt": + os.chmod(root / "final-receipt.json", 0o644) + else: + original = root / "collections" + target = root / "collections-real" + original.rename(target) + os.symlink(target.name, original) + with pytest.raises(WorkflowError): + verify_seal(root, expected_sha256=expected) + + +def test_sealing_rejects_unsafe_roots_and_incomplete_contracts(tmp_path: Path) -> None: + unsafe = tmp_path / "unsafe" + write_minimal_run(unsafe) + target = unsafe / "outside" + target.touch() + os.symlink(target, unsafe / "seal.lock") + with pytest.raises(WorkflowError): + seal_run(unsafe, session_id="unsafe") + + incomplete = tmp_path / "incomplete" + incomplete.mkdir() + (incomplete / "prompt.md").write_text("task\n", encoding="utf-8") + with pytest.raises(WorkflowError, match="missing artifacts"): + seal_run(incomplete, session_id="incomplete") + + +def test_read_only_transition_covers_optional_evidence_without_following_symlinks(tmp_path: Path) -> None: + root = tmp_path / "run" + assignment = root / "assignments" / "child" / "record.json" + assignment.parent.mkdir(parents=True) + assignment.write_text("{}\n", encoding="utf-8") + make_read_only(root) + assert assignment.stat().st_mode & 0o222 == 0 + + outside = tmp_path / "outside.json" + outside.write_text("{}\n", encoding="utf-8") + root2 = tmp_path / "run2" + (root2 / "assignments").mkdir(parents=True) + os.symlink(outside, root2 / "assignments" / "linked.json") + before = outside.stat().st_mode + with pytest.raises(WorkflowError, match="symlink"): + make_read_only(root2) + assert outside.stat().st_mode == before + + +def test_json_pointer_binding_subset_rejects_ambiguous_or_invalid_paths() -> None: + from agent_workflow.bindings import resolve_json_pointer + + document = {"items": [{"id": "a"}], "a/b": {"~key": 3}} + assert resolve_json_pointer(document, "/items/0/id") == "a" + assert resolve_json_pointer(document, "/a~1b/~0key") == 3 + with pytest.raises(WorkflowError, match="invalid escape"): + resolve_json_pointer(document, "/bad~2escape") + with pytest.raises(WorkflowError, match="start with"): + resolve_json_pointer(document, "items/0") diff --git a/tests/live/test_live_adapters.py b/tests/live/test_live_adapters.py new file mode 100644 index 0000000..ceee1ac --- /dev/null +++ b/tests/live/test_live_adapters.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import uuid +from pathlib import Path + +import pytest + +from tests.conftest import InstalledProduct, fake_agent_path, git_repo, wait_for_status + + +def _real_host_env(product_env: dict[str, str]) -> dict[str, str]: + env = dict(product_env) + path_parts = env.get("PATH", "").split(os.pathsep) + # product_env prepends the deterministic fake-command directory. Live tests + # keep the installed wheel but deliberately cross the real host tmux boundary. + env["PATH"] = os.pathsep.join(path_parts[1:]) + return env + + +def _cleanup_tmux(session_id: str, env: dict[str, str]) -> None: + tmux = shutil.which("tmux", path=env.get("PATH")) + if tmux: + subprocess.run( + [tmux, "kill-session", "-t", session_id], + env=env, + text=True, + capture_output=True, + timeout=15, + check=False, + ) + + +@pytest.mark.live +def test_real_tmux_runs_the_installed_product_to_a_sealed_terminal_receipt( + installed_product: InstalledProduct, + product_env: dict[str, str], + fake_agent_path: Path, + tmp_path: Path, +) -> None: + if os.environ.get("AGENT_WORKFLOW_LIVE_TMUX") != "1": + pytest.skip("set AGENT_WORKFLOW_LIVE_TMUX=1") + env = _real_host_env(product_env) + if not shutil.which("tmux", path=env.get("PATH")): + pytest.skip("real tmux is not installed") + + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text("Read README.md and complete without modifying files.\n", encoding="utf-8") + session_id = f"live-tmux-{uuid.uuid4().hex[:10]}" + try: + installed_product.json( + "launch", + session_id, + repo, + prompt, + "--tier", + "low", + "--no-interactive", + "--", + fake_agent_path, + env=env, + ) + status = wait_for_status(env, session_id, timeout=60) + assert status["status"] == "completed" + run_dir = Path(env["XDG_STATE_HOME"]) / "agent-workflow" / "runs" / session_id + assert (run_dir / "final-receipt.json").is_file() + assert (run_dir / "output.log").read_text(encoding="utf-8").strip() + finally: + _cleanup_tmux(session_id, env) + + +@pytest.mark.live +def test_real_configured_executor_completes_a_read_only_smoke_journey( + installed_product: InstalledProduct, + product_env: dict[str, str], + tmp_path: Path, +) -> None: + executor = os.environ.get("AGENT_WORKFLOW_LIVE_EXECUTOR") + if executor not in {"codex", "claude"}: + pytest.skip("set AGENT_WORKFLOW_LIVE_EXECUTOR=codex or claude") + env = _real_host_env(product_env) + if not shutil.which("tmux", path=env.get("PATH")): + pytest.skip("real tmux is not installed") + if not shutil.which(executor, path=env.get("PATH")): + pytest.skip(f"configured executor is not installed: {executor}") + + repo = tmp_path / "repo" + git_repo(repo) + prompt = tmp_path / "prompt.md" + prompt.write_text( + "Inspect README.md. Do not edit any files. Produce the required completion handoff.\n", + encoding="utf-8", + ) + session_id = f"live-{executor}-{uuid.uuid4().hex[:10]}" + try: + installed_product.json( + "launch", + session_id, + repo, + prompt, + "--ticket", + "LIVE-SMOKE", + "--tier", + "low", + "--executor", + executor, + "--structured", + "--no-interactive", + env=env, + timeout=60, + ) + status = wait_for_status(env, session_id, timeout=600) + assert status["status"] == "completed" + run_dir = Path(env["XDG_STATE_HOME"]) / "agent-workflow" / "runs" / session_id + assert (run_dir / "final-receipt.json").is_file() + assert (run_dir / "completion.json").is_file() + finally: + _cleanup_tmux(session_id, env) diff --git a/tests/release/test_distribution.py b/tests/release/test_distribution.py new file mode 100644 index 0000000..04be583 --- /dev/null +++ b/tests/release/test_distribution.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import re +import subprocess + +import jsonschema + +from tests.conftest import InstalledProduct, REPO_ROOT + + +def test_release_asset_audit_is_the_single_static_repository_gate() -> None: + result = subprocess.run( + ["python3", "scripts/audit-release-assets.py"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_all_published_json_schemas_are_valid_draft_2020_12() -> None: + schemas = sorted((REPO_ROOT / "schemas").glob("*.json")) + assert schemas + for path in schemas: + jsonschema.Draft202012Validator.check_schema(json.loads(path.read_text(encoding="utf-8"))) + + +def test_shell_entrypoints_and_installer_are_syntax_valid() -> None: + paths = [REPO_ROOT / "install.sh", REPO_ROOT / "uninstall.sh", REPO_ROOT / "bin" / "agent-workflow"] + paths.extend(sorted((REPO_ROOT / "scripts").glob("*.sh"))) + for path in paths: + subprocess.run(["bash", "-n", str(path)], check=True) + + +def test_documented_commands_match_the_installed_public_surface( + installed_product: InstalledProduct, product_env: dict[str, str] +) -> None: + help_text = installed_product.run("--help", env=product_env, check=True).stdout + command_group = re.search(r"\{([a-z][a-z0-9,-]+)\}", help_text) + assert command_group is not None, help_text + public_commands = set(command_group.group(1).split(",")) + assert {"launch", "status", "workflow", "pack", "eval"} <= public_commands + + canonical_docs = [REPO_ROOT / "README.md", *sorted((REPO_ROOT / "docs").glob("*.md"))] + documented: set[str] = set() + for path in canonical_docs: + text = path.read_text(encoding="utf-8") + fence_languages = "bash|sh|shell|text" if path.name == "COMMAND_REFERENCE.md" else "bash|sh|shell" + blocks = re.findall(rf"```(?:{fence_languages})\n(.*?)```", text, flags=re.DOTALL) + for block in blocks: + for match in re.finditer( + r"^\s*agent-workflow\s+([a-z][a-z0-9-]+)(?:\s|$)", + block, + flags=re.MULTILINE, + ): + documented.add(match.group(1)) + assert documented <= public_commands, f"unknown documented commands: {sorted(documented - public_commands)}" + + command_reference = (REPO_ROOT / "docs" / "COMMAND_REFERENCE.md").read_text(encoding="utf-8") + assert "agent-workflow eval compare BASELINE.json CANDIDATE.json --output PATH" in command_reference diff --git a/tests/run_fixtures.py b/tests/support.py similarity index 55% rename from tests/run_fixtures.py rename to tests/support.py index 82c3a97..d52a599 100644 --- a/tests/run_fixtures.py +++ b/tests/support.py @@ -1,12 +1,13 @@ +from __future__ import annotations + from pathlib import Path +from typing import Any from agent_workflow.receipts import initial_completion, initial_provenance from agent_workflow.util import atomic_write_json, sha256_file -def write_run_contracts( - root: Path, *, session_id: str = "test-run", include_final: bool = True -) -> None: +def write_minimal_run(root: Path, *, session_id: str = "test-run", terminal: str = "completed") -> None: root.mkdir(parents=True, exist_ok=True) for name, content in { "prompt.md": "task\n", @@ -14,38 +15,25 @@ def write_run_contracts( "completion.md": "completion\n", "executor-events.jsonl": "", "executor-stderr.log": "", - "output.log": "", + "output.log": "done\n", "patch.diff": "", }.items(): (root / name).write_text(content, encoding="utf-8") atomic_write_json( root / "command.json", - { - "schema": "agent-workflow/command/v1", - "argv": ["cat"], - "shell": "cat", - "executor": None, - "stream_format": "text", - }, + {"schema": "agent-workflow/command/v1", "argv": ["cat"], "shell": "cat", "executor": None, "stream_format": "text"}, ) atomic_write_json( root / "source-baseline.json", { "schema": "agent-workflow/source-baseline/v1", "generated_at": "2026-01-01T00:00:00+00:00", - "components": { - "primary": {"path": str(root), "head": "", "branch": "", "dirty": False} - }, + "components": {"primary": {"path": str(root), "head": "", "branch": "", "dirty": False}}, }, ) atomic_write_json( root / "completion.json", - initial_completion( - session_id=session_id, - ticket_id=None, - pack_id=None, - base_revision=None, - ), + initial_completion(session_id=session_id, ticket_id=None, pack_id=None, base_revision=None), ) atomic_write_json( root / "collections" / "completion.json", @@ -81,7 +69,7 @@ def write_run_contracts( environment={}, ), ) - status = { + status: dict[str, Any] = { "schema": "agent-workflow/session-status/v2", "session_id": session_id, "status": "launched", @@ -98,5 +86,48 @@ def write_run_contracts( "evaluation_path": None, } atomic_write_json(root / "status.json", status) - if include_final: - atomic_write_json(root / "final-status.json", {**status, "status": "completed"}) + atomic_write_json(root / "final-status.json", {**status, "status": terminal}) + + +def trial(trial_id: str, verdict: str, *, task_id: str = "task-1", repetition: int = 0) -> dict[str, Any]: + nullable = { + "duration_seconds": 1.0, + "input_tokens": None, + "cached_input_tokens": None, + "cache_write_input_tokens": None, + "output_tokens": None, + "reasoning_output_tokens": None, + "provider_total_tokens": None, + "tokens": None, + "provider_billed_cost": None, + "local_estimated_cost": None, + "currency": None, + "price_catalog_id": None, + "retry_of_run_id": None, + "retry_count": 0, + "steer_count": 0, + "steer_acknowledged_count": 0, + } + return { + "schema": "agent-workflow/trial-evidence/v2", + "trial_id": trial_id, + "run_path": f"/runs/{trial_id}", + "final_receipt_sha256": "1" * 64, + "provider_evidence_sha256": "2" * 64, + "raw_events_sha256": "3" * 64, + "verdict": verdict, + "fixture_revision": "fixture-v1", + "task_id": task_id, + "base_revision": "base-v1", + "prompt_sha256": "4" * 64, + "oracle_sha256": "5" * 64, + "acceptance_commands_sha256": "6" * 64, + "scope_policy_sha256": "7" * 64, + "scorer_versions_sha256": "8" * 64, + "sandbox": "docker", + "budget_sha256": "9" * 64, + "repetition": repetition, + "errors": [], + "source_artifacts": {}, + **nullable, + } diff --git a/tests/test_agent_context.py b/tests/test_agent_context.py deleted file mode 100644 index dd28ada..0000000 --- a/tests/test_agent_context.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -import json -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.agent_context import ( - auto_reuse, - candidates, - complete_task, - initialize, - read, - request_reuse, -) -from agent_workflow.config import defaults -from agent_workflow.sessions import acknowledge -from agent_workflow.state import run_dir, write_status - - -class AgentContextTests(unittest.TestCase): - def _run(self, root: Path, *, ticket: str = "T-1", interactive: bool = True): - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state", "reuse_stale_minutes": 120} - ) - worktree = root / "worktree" - worktree.mkdir(exist_ok=True) - state_dir = run_dir(settings, "run-1") - state_dir.mkdir(parents=True, exist_ok=True) - prompt = state_dir / "prompt.md" - prompt.write_text("task one", encoding="utf-8") - status = { - "schema": "agent-workflow/session-status/v2", - "session_id": "run-1", - "ticket_id": ticket, - "pack_id": "pack-1", - "retry_of": None, - "agent_name": "curly", - "agent_class": "implementation", - "executor": "codex", - "status": "running", - "created_at": "2026-07-24T00:00:00+00:00", - "workdir": str(worktree), - "prompt_path": str(prompt), - "prompt_sha256": "0" * 64, - "log_path": str(state_dir / "output.log"), - "repository_root": str(worktree), - "source_revision": "abc", - "tmux_session": "host", - "tmux_target": "host:0.1", - } - write_status(settings, "run-1", status) - initialize( - state_dir, - session_id="run-1", - status=status, - command={"interactive": interactive, "model": "gpt-5.4-mini"}, - ) - return settings, worktree, state_dir - - @patch("agent_workflow.agent_context.tmux.pane_info", return_value=object()) - @patch("agent_workflow.agent_context.tmux.session_exists", return_value=True) - def test_completion_is_required_before_candidate_and_cross_worktree_is_rejected( - self, _exists, _pane - ): - with tempfile.TemporaryDirectory() as tmp: - settings, worktree, _ = self._run(Path(tmp)) - self.assertFalse(candidates(settings, workdir=worktree)[0]["eligible"]) - context = complete_task( - settings, "run-1", actor="curly", summary="Implemented parser", - tags=["parser"], files=["src/parser.py"], - ) - self.assertEqual(context["state"], "idle_reusable") - same = candidates(settings, workdir=worktree, ticket_id="T-1", tags=["parser"])[0] - self.assertTrue(same["eligible"]) - self.assertTrue(same["auto_reuse_eligible"]) - other = Path(tmp) / "other" - other.mkdir() - cross = candidates(settings, workdir=other, ticket_id="T-1")[0] - self.assertFalse(cross["eligible"]) - self.assertIn("different_worktree", cross["reasons"]) - - @patch("agent_workflow.agent_context.tmux.pane_info", return_value=object()) - @patch("agent_workflow.agent_context.tmux.session_exists", return_value=True) - def test_reuse_waits_for_correlated_ack(self, _exists, _pane): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings, worktree, _ = self._run(root) - complete_task(settings, "run-1", actor="curly", summary="Done") - prompt = root / "second.md" - prompt.write_text("task two", encoding="utf-8") - pending = request_reuse( - settings, "run-1", prompt_path=prompt, actor="orchestrator", - ticket_id="T-1", pack_id="pack-1", automatic=True, - ) - self.assertEqual(read(settings, "run-1")["state"], "reuse_pending") - acknowledge( - settings, "run-1", actor="curly", content="accepted", - correlation_id=pending["message"]["message_id"], - ) - context = read(settings, "run-1") - self.assertEqual(context["state"], "busy") - self.assertEqual(context["reuse_count"], 1) - - @patch("agent_workflow.agent_context.tmux.pane_info", return_value=object()) - @patch("agent_workflow.agent_context.tmux.session_exists", return_value=True) - def test_auto_reuse_refuses_similarity_without_exact_lineage(self, _exists, _pane): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings, worktree, _ = self._run(root) - complete_task( - settings, "run-1", actor="curly", summary="Parser work", tags=["parser"] - ) - prompt = root / "next.md" - prompt.write_text("similar task", encoding="utf-8") - result = auto_reuse( - settings, workdir=worktree, prompt_path=prompt, - actor="orchestrator", ticket_id="T-2", pack_id="pack-1", - retry_of=None, agent_class="implementation", tags=["parser"], - ) - self.assertEqual(result["action"], "launch") - self.assertFalse(result["candidates"][0]["auto_reuse_eligible"]) - - @patch("agent_workflow.agent_context.tmux.pane_info", return_value=object()) - @patch("agent_workflow.agent_context.tmux.session_exists", return_value=True) - def test_stale_idle_agent_is_not_eligible(self, _exists, _pane): - with tempfile.TemporaryDirectory() as tmp: - settings, worktree, state_dir = self._run(Path(tmp)) - complete_task(settings, "run-1", actor="curly", summary="Done") - path = state_dir / "agent-context.json" - context = json.loads(path.read_text(encoding="utf-8")) - context["completed_assignments"][-1]["completed_at"] = "2000-01-01T00:00:00+00:00" - path.write_text(json.dumps(context), encoding="utf-8") - candidate = candidates(settings, workdir=worktree, ticket_id="T-1")[0] - self.assertFalse(candidate["eligible"]) - self.assertIn("idle_stale", candidate["reasons"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_approval.py b/tests/test_approval.py deleted file mode 100644 index aef7a51..0000000 --- a/tests/test_approval.py +++ /dev/null @@ -1,414 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import tempfile -import unittest -from dataclasses import replace -from pathlib import Path - -from agent_workflow.approval import accepted_lifecycle_receipt, is_approved -from agent_workflow.config import defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.ledger import build_ledger -from agent_workflow.scheduler import SchedulerService -from agent_workflow.lifecycle import record -from agent_workflow.util import atomic_write_json -from agent_workflow.workflow import ( - normalize_snapshot, - record_workflow_binding, - record_workflow_transition, - snapshot_sha256, -) -from run_fixtures import write_run_contracts - - -def _completed_run(root: Path, session_id: str) -> Path: - run = root / "state" / "runs" / session_id - write_run_contracts(run, session_id=session_id) - completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) - completion.update(result="completed", head_revision="abc123", unresolved=[]) - atomic_write_json(run / "completion.json", completion) - atomic_write_json( - run / "evaluation-runtime.json", - { - "schema": "agent-workflow/evaluation-runtime/v1", - "scorers": ["acceptance_commands"], - }, - ) - from agent_workflow.receipts import seal_run - - seal_run(run, session_id=session_id) - status = json.loads((run / "status.json").read_text(encoding="utf-8")) - status.update( - status="completed", - disposition=None, - final_receipt_path=str(run / "final-receipt.json"), - final_receipt_sha256=hashlib.sha256( - (run / "final-receipt.json").read_bytes() - ).hexdigest(), - tier="medium", - ) - atomic_write_json(run / "status.json", status) - scores = run / "scores" - scores.mkdir() - score_receipts = [] - for scorer_id in ("schema_validity", "acceptance_commands"): - score_receipt = { - "schema": "agent-workflow/score-receipt/v1", - "scorer": {"id": scorer_id, "version": "1"}, - "final_receipt_sha256": status["final_receipt_sha256"], - "verdict": "pass", - "facts": {}, - "evidence": [], - } - encoded = json.dumps( - score_receipt, sort_keys=True, separators=(",", ":") - ).encode() - digest = hashlib.sha256(encoded).hexdigest() - atomic_write_json(scores / f"{scorer_id}-{digest}.json", score_receipt, mode=0o444) - score_receipts.append(score_receipt) - atomic_write_json( - scores / "score-set.json", - { - "schema": "agent-workflow/score-set/v1", - "final_receipt_sha256": status["final_receipt_sha256"], - "verdict": "pass", - "scores": score_receipts, - }, - ) - return run - - -class ApprovalGateTests(unittest.TestCase): - def test_accepted_receipt_satisfies_approval(self): - with tempfile.TemporaryDirectory() as tmp: - run = _completed_run(Path(tmp), "approval-accepted") - settings = type("S", (), {"state_root": Path(tmp) / "state"})() - record( - settings, - "approval-accepted", - action="reviewed", - actor="reviewer", - reason="review", - ) - record( - settings, - "approval-accepted", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - receipt = accepted_lifecycle_receipt(run) - self.assertEqual(receipt["action"], "accepted") - self.assertTrue(is_approved(run)) - - def test_lifecycle_receipt_root_symlink_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - run = _completed_run(root, "approval-root-symlink") - outside = root / "outside-receipts" - outside.mkdir() - os.symlink(outside, run / "receipts") - settings = type("S", (), {"state_root": root / "state"})() - with self.assertRaisesRegex(WorkflowError, "receipt root is unsafe"): - record( - settings, - "approval-root-symlink", - action="reviewed", - actor="reviewer", - reason="review", - ) - - def test_rejected_tampered_unrelated_and_stale_receipts_fail_closed(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - run = _completed_run(root, "approval-primary") - settings = type("S", (), {"state_root": root / "state"})() - record( - settings, - "approval-primary", - action="rejected", - actor="reviewer", - reason="reject", - ) - self.assertFalse(is_approved(run)) - - record( - settings, - "approval-primary", - action="reviewed", - actor="reviewer", - reason="review", - ) - record( - settings, - "approval-primary", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - receipt_path = run / "receipts" / "000003-accepted.json" - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - receipt["final_receipt_sha256"] = "0" * 64 - atomic_write_json(receipt_path, receipt) - self.assertFalse(is_approved(run)) - - other = _completed_run(root, "approval-other") - other_settings = type("S", (), {"state_root": root / "state"})() - record( - other_settings, - "approval-other", - action="reviewed", - actor="reviewer", - reason="review", - ) - record( - other_settings, - "approval-other", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - status_path = run / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status["lifecycle_receipt_path"] = str( - other / "receipts" / "000002-accepted.json" - ) - atomic_write_json(status_path, status) - self.assertFalse(is_approved(run)) - - stale_status = json.loads(status_path.read_text(encoding="utf-8")) - stale_status["lifecycle_receipt_path"] = str( - run / "receipts" / "000003-accepted.json" - ) - stale_status["final_receipt_sha256"] = "f" * 64 - atomic_write_json(status_path, stale_status) - self.assertFalse(is_approved(run)) - - canonical = _completed_run(root, "approval-canonical") - record( - settings, - "approval-canonical", - action="reviewed", - actor="reviewer", - reason="review", - ) - record( - settings, - "approval-canonical", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - canonical_status_path = canonical / "status.json" - canonical_status = json.loads( - canonical_status_path.read_text(encoding="utf-8") - ) - canonical_status["lifecycle_receipt_path"] = str( - other / "receipts" / "000002-accepted.json" - ) - canonical_status["final_receipt_sha256"] = "f" * 64 - atomic_write_json(canonical_status_path, canonical_status) - self.assertTrue(is_approved(canonical)) - - def test_downstream_eligibility_follows_receipt_evidence(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - pack_root = root / "pack" - pack_root.mkdir() - (pack_root / "phase-1").mkdir() - (pack_root / "phase-1" / "task-manifest.yaml").write_text( - "phase: 1\nname: gates\ntasks:\n - id: DEP\n tier: A\n session: approval-dependency\n prompt: tickets/DEP.md\n - id: DOWN\n tier: A\n session: approval-dependent\n prompt: tickets/DOWN.md\n dependencies: [DEP]\n", - encoding="utf-8", - ) - dep_run = _completed_run(root, "approval-dependency") - dep_settings = type("S", (), {"state_root": root / "state"})() - record( - dep_settings, - "approval-dependency", - action="reviewed", - actor="reviewer", - reason="review", - ) - record( - dep_settings, - "approval-dependency", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - down_run = root / "state" / "runs" / "approval-dependent" - write_run_contracts(down_run, session_id="approval-dependent", include_final=False) - atomic_write_json( - down_run / "status.json", - { - "schema": "agent-workflow/session-status/v2", - "session_id": "approval-dependent", - "status": "missing", - "disposition": None, - "created_at": "2026-01-01T00:00:00+00:00", - "updated_at": "2026-01-01T00:00:00+00:00", - "workdir": str(down_run), - "prompt_path": str(down_run / "prompt.md"), - "log_path": str(down_run / "output.log"), - }, - ) - ledger = build_ledger(pack_root, root / "state" / "runs") - row = next(item for item in ledger["rows"] if item["ticket"] == "DOWN") - self.assertEqual(row["next_action"], "agent-workflow launch approval-dependent ...") - self.assertTrue(is_approved(dep_run)) - - def test_scheduler_advances_accepted_gate_and_launches_downstream(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(), state_root=root / "state") - subject_run = _completed_run(root, "approval-subject") - record(settings, "approval-subject", action="reviewed", actor="reviewer", reason="review") - record( - settings, - "approval-subject", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - workflow_dir = root / "workflow" - workflow_dir.mkdir() - snapshot = normalize_snapshot( - { - "workflow_id": "accepted-gate-workflow", - "pack_id": "pack", - "pack_manifest_sha256": "a" * 64, - "nodes": [ - { - "node_id": "subject", - "session_id": "approval-subject", - "prompt_path": "subject.md", - "dependencies": [], - }, - { - "node_id": "gate", - "kind": "approval", - "approval_for": "subject", - "dependencies": ["subject"], - }, - { - "node_id": "downstream", - "session_id": "approval-downstream", - "prompt_path": "downstream.md", - "dependencies": ["gate"], - }, - ], - } - ) - digest = snapshot_sha256(snapshot) - record_workflow_binding( - workflow_dir, - workflow_id=snapshot["workflow_id"], - node_id="subject", - run_id="approval-subject", - attempt=1, - actor="scheduler", - reason="launch", - snapshot_sha256=digest, - ) - record_workflow_transition( - workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", - actor="scheduler", reason="running", snapshot_sha256=digest, - previous_state="eligible", next_state="running", - ) - record_workflow_transition( - workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", - actor="scheduler", reason="completed", snapshot_sha256=digest, - previous_state="running", next_state="completed", - ) - launched = [] - - def launch(node, run_id): - launched.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - scheduler = SchedulerService( - settings=settings, - run_dir=workflow_dir, - workdir=root, - launch_fn=launch, - ) - result = scheduler.launch_eligible(snapshot) - status = scheduler.status(snapshot) - states = {item["node_id"]: item["state"] for item in status["nodes"]} - self.assertEqual(states["gate"], "completed") - self.assertEqual(states["downstream"], "running") - self.assertEqual(launched, ["approval-downstream"]) - self.assertEqual(result["plans"][0]["node_id"], "downstream") - gate = next(item for item in status["nodes"] if item["node_id"] == "gate") - self.assertEqual( - gate["approval_receipt_sha256"], - hashlib.sha256((subject_run / "receipts" / "000002-accepted.json").read_bytes()).hexdigest(), - ) - - def test_scheduler_rejected_or_tampered_gate_fails_downstream_closed(self): - for disposition in ("rejected", "tampered"): - with self.subTest(disposition=disposition), tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(), state_root=root / "state") - subject_run = _completed_run(root, "approval-subject") - if disposition == "rejected": - record(settings, "approval-subject", action="rejected", actor="reviewer", reason="reject") - else: - record(settings, "approval-subject", action="reviewed", actor="reviewer", reason="review") - record( - settings, "approval-subject", action="accepted", actor="reviewer", - reason="approved", revision="abc123", - ) - receipt_path = subject_run / "receipts" / "000002-accepted.json" - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - receipt["completion_sha256"] = "0" * 64 - atomic_write_json(receipt_path, receipt) - workflow_dir = root / "workflow" - workflow_dir.mkdir() - snapshot = normalize_snapshot( - { - "workflow_id": f"{disposition}-gate-workflow", - "pack_id": "pack", - "pack_manifest_sha256": "b" * 64, - "nodes": [ - {"node_id": "subject", "session_id": "approval-subject", "prompt_path": "subject.md", "dependencies": []}, - {"node_id": "gate", "kind": "approval", "approval_for": "subject", "dependencies": ["subject"]}, - {"node_id": "downstream", "session_id": "approval-downstream", "prompt_path": "downstream.md", "dependencies": ["gate"]}, - ], - } - ) - digest = snapshot_sha256(snapshot) - record_workflow_binding( - workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", - run_id="approval-subject", attempt=1, actor="scheduler", reason="launch", - snapshot_sha256=digest, - ) - record_workflow_transition( - workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", - actor="scheduler", reason="running", snapshot_sha256=digest, - previous_state="eligible", next_state="running", - ) - record_workflow_transition( - workflow_dir, workflow_id=snapshot["workflow_id"], node_id="subject", - actor="scheduler", reason="completed", snapshot_sha256=digest, - previous_state="running", next_state="completed", - ) - scheduler = SchedulerService( - settings=settings, run_dir=workflow_dir, workdir=root, - launch_fn=lambda node, run_id: self.fail("downstream must not launch"), - ) - result = scheduler.launch_eligible(snapshot) - states = {item["node_id"]: item["state"] for item in scheduler.status(snapshot)["nodes"]} - self.assertEqual(states["gate"], "failed") - self.assertEqual(states["downstream"], "failed") - self.assertEqual(result["plans"], []) diff --git a/tests/test_bindings.py b/tests/test_bindings.py deleted file mode 100644 index d9c9c72..0000000 --- a/tests/test_bindings.py +++ /dev/null @@ -1,179 +0,0 @@ -from __future__ import annotations - -import json -import os -import tempfile -import unittest -from dataclasses import replace -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.bindings import resolve_json_pointer, resolve_node_inputs -from agent_workflow.config import defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json, sha256_file -from agent_workflow.workflow import initial_status, normalize_snapshot -from run_fixtures import write_run_contracts - - -def _sealed_result_run(settings, run_id: str, value: dict[str, object]) -> Path: - run = settings.state_root / "runs" / run_id - write_run_contracts(run, session_id=run_id) - atomic_write_json(run / "result.json", value) - digest = sha256_file(run / "result.json") - atomic_write_json( - run / "collections" / "task-result.json", - { - "schema": "agent-workflow/task-result-collection/v1", - "session_id": run_id, - "required": True, - "schema_path": "result.schema.json", - "source_path": "handoff/result.json", - "source_sha256": digest, - "stored_path": "result.json", - "stored_sha256": digest, - "validation_status": "valid", - "validation_errors": [], - "collected_at": "2026-07-24T00:00:00+00:00", - }, - ) - seal_run(run, session_id=run_id) - return run - - -def _binding_fixture(root: Path): - settings = replace(defaults(), state_root=root / "state") - _sealed_result_run(settings, "source-run", {"nested": {"answer": 42}, "items": ["a", "b"]}) - snapshot = normalize_snapshot( - { - "workflow_id": "binding-workflow", - "pack_id": "pack", - "pack_manifest_sha256": "a" * 64, - "nodes": [ - { - "node_id": "source", - "session_id": "source-run", - "prompt_path": "source.md", - "dependencies": [], - }, - { - "node_id": "target", - "session_id": "target-run", - "prompt_path": "target.md", - "dependencies": ["source"], - "input_bindings": { - "answer": { - "source_node_id": "source", - "pointer": "/nested/answer", - "required": True, - "max_bytes": 128, - } - }, - }, - ], - } - ) - status = initial_status(snapshot) - states = {item["node_id"]: item for item in status["nodes"]} - states["source"].update(state="completed", run_id="source-run", attempt=1) - states["target"]["state"] = "eligible" - node = next(item for item in snapshot["nodes"] if item["node_id"] == "target") - return settings, snapshot, status, node - - -class WorkflowBindingTests(unittest.TestCase): - def test_json_pointer_rejects_invalid_escapes_and_array_aliases(self): - with self.assertRaisesRegex(WorkflowError, "invalid escape"): - resolve_json_pointer({"a": 1}, "/~2") - self.assertIsNot(resolve_json_pointer(["zero"], "/00"), "zero") - self.assertEqual(resolve_json_pointer({"a/b": {"~": 7}}, "/a~1b/~0"), 7) - - def test_binding_snapshot_replay_is_idempotent_and_preserves_creation_time(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings, snapshot, status, node = _binding_fixture(root) - workflow_dir = root / "workflow" - with patch("agent_workflow.bindings.utc_now", return_value="2026-07-24T01:00:00+00:00"): - first = resolve_node_inputs( - snapshot=snapshot, - status=status, - node=node, - settings=settings, - workflow_run_dir=workflow_dir, - attempt=1, - ) - with patch("agent_workflow.bindings.utc_now", return_value="2099-01-01T00:00:00+00:00"): - second = resolve_node_inputs( - snapshot=snapshot, - status=status, - node=node, - settings=settings, - workflow_run_dir=workflow_dir, - attempt=1, - ) - assert first is not None and second is not None - self.assertEqual(first["sha256"], second["sha256"]) - self.assertEqual(second["artifact"]["created_at"], "2026-07-24T01:00:00+00:00") - self.assertEqual(second["artifact"]["bindings"][0]["value"], 42) - self.assertEqual(0, Path(second["path"]).stat().st_mode & 0o222) - - def test_existing_binding_snapshot_must_be_regular_read_only_and_match_sources(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings, snapshot, status, node = _binding_fixture(root) - workflow_dir = root / "workflow" - evidence = resolve_node_inputs( - snapshot=snapshot, - status=status, - node=node, - settings=settings, - workflow_run_dir=workflow_dir, - attempt=1, - ) - assert evidence is not None - path = Path(evidence["path"]) - os.chmod(path, 0o644) - with self.assertRaisesRegex(WorkflowError, "read-only"): - resolve_node_inputs( - snapshot=snapshot, - status=status, - node=node, - settings=settings, - workflow_run_dir=workflow_dir, - attempt=1, - ) - - def test_binding_source_must_be_an_ancestor(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings, snapshot, status, node = _binding_fixture(root) - detached = dict(node) - detached["dependencies"] = [] - detached["input_bindings"] = node["input_bindings"] - altered = normalize_snapshot( - { - **snapshot, - "nodes": [ - next(item for item in snapshot["nodes"] if item["node_id"] == "source"), - detached, - ], - } - ) - altered_node = next(item for item in altered["nodes"] if item["node_id"] == "target") - altered_status = initial_status(altered) - states = {item["node_id"]: item for item in altered_status["nodes"]} - states["source"].update(state="completed", run_id="source-run", attempt=1) - with self.assertRaisesRegex(WorkflowError, "predecessor"): - resolve_node_inputs( - snapshot=altered, - status=altered_status, - node=altered_node, - settings=settings, - workflow_run_dir=root / "workflow", - attempt=1, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_cli_parsing.py b/tests/test_cli_parsing.py deleted file mode 100644 index 301e056..0000000 --- a/tests/test_cli_parsing.py +++ /dev/null @@ -1,192 +0,0 @@ -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.cli import _parse_args, build_parser, main -from agent_workflow.errors import InteractiveCapacityError - - -class CliParsingTests(unittest.TestCase): - def test_launch_options_can_follow_positionals(self): - args = _parse_args( - build_parser(), - [ - "launch", - "sample-p0-01", - "worktree", - "ticket.md", - "--ticket", - "P0-01", - "--pack", - "sample-pack", - "--job", - "jobs/P0-01.json", - "--executor", - "codex", - "--model", - "gpt-5.4-mini", - ], - ) - self.assertEqual(args.ticket, "P0-01") - self.assertEqual(args.executor, "codex") - self.assertEqual(args.model, "gpt-5.4-mini") - self.assertEqual(str(args.job), "jobs/P0-01.json") - self.assertIsNone(args.explicit_command) - - def test_explicit_command_is_preserved_after_separator(self): - args = _parse_args( - build_parser(), - [ - "launch", - "sample-p0-01", - "worktree", - "ticket.md", - "--ticket", - "P0-01", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "-", - ], - ) - self.assertEqual( - args.explicit_command, - ["codex", "exec", "--sandbox", "workspace-write", "-"], - ) - - def test_global_json_can_follow_subcommand(self): - args = _parse_args(build_parser(), ["doctor", "--json"]) - self.assertEqual(args.command, "doctor") - self.assertTrue(args.json) - - def test_interactive_model_policy_flags_parse(self): - args = _parse_args( - build_parser(), - [ - "launch", "run", "work", "prompt.md", "--executor", "claude", - "--model", "opus", "--interactive", "--allow-no-go-model", - ], - ) - self.assertTrue(args.interactive) - self.assertTrue(args.allow_no_go_model) - self.assertEqual(args.model, "opus") - - def test_pane_limit_action_parses(self): - args = _parse_args( - build_parser(), - ["launch", "run", "work", "prompt", "--pane-limit-action", "close-idle"], - ) - self.assertEqual(args.pane_limit_action, "close-idle") - - def test_prompt_can_choose_detached_non_interactive_launch(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - work = root / "work" - work.mkdir() - prompt = root / "prompt.md" - prompt.write_text("task", encoding="utf-8") - capacity = InteractiveCapacityError(count=3, maximum=3, idle_sessions=[]) - with ( - patch("agent_workflow.cli.launch_session", side_effect=[capacity, {"status": "launched"}]) as launch, - patch("agent_workflow.cli.sys.stdin.isatty", return_value=True), - patch("builtins.input", return_value="n"), - ): - self.assertEqual( - main(["launch", "run", str(work), str(prompt), "--executor", "codex"]), - 0, - ) - self.assertIsNone(launch.call_args_list[0].kwargs["interactive"]) - self.assertFalse(launch.call_args_list[1].kwargs["interactive"]) - - def test_explicit_close_idle_action_closes_only_required_sessions(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - work = root / "work" - work.mkdir() - prompt = root / "prompt.md" - prompt.write_text("task", encoding="utf-8") - capacity = InteractiveCapacityError( - count=3, - maximum=3, - idle_sessions=[ - {"session_id": "idle-1", "agent_name": "larry", "state": "idle_reusable"} - ], - ) - with ( - patch("agent_workflow.cli.launch_session", side_effect=[capacity, {"status": "launched"}]), - patch("agent_workflow.cli.kill_session") as kill, - ): - self.assertEqual( - main([ - "launch", "run", str(work), str(prompt), "--executor", "codex", - "--pane-limit-action", "close-idle", - ]), - 0, - ) - kill.assert_called_once() - self.assertEqual(kill.call_args.args[1], "idle-1") - - def test_global_config_can_follow_subcommand(self): - args = _parse_args( - build_parser(), ["doctor", "--config", "workflow.toml"] - ) - self.assertEqual(str(args.config), "workflow.toml") - - def test_control_commands_preserve_message_contract_inputs(self): - steer = _parse_args( - build_parser(), ["steer", "run-1", "inspect tests", "--actor", "parent"] - ) - watch = _parse_args( - build_parser(), ["watch", "run-1", "--after", "7", "--timeout", "1.5"] - ) - self.assertEqual((steer.command, steer.actor, steer.content), ("steer", "parent", "inspect tests")) - self.assertEqual((watch.command, watch.after, watch.timeout), ("watch", 7, 1.5)) - - def test_workflow_commands_parse_paths(self): - validate = _parse_args(build_parser(), ["workflow", "validate", "snapshot.json"]) - start = _parse_args(build_parser(), ["workflow", "start", "run", "snapshot.json"]) - status = _parse_args(build_parser(), ["workflow", "status", "run", "snapshot.json"]) - resume = _parse_args(build_parser(), ["workflow", "resume", "run", "snapshot.json"]) - self.assertEqual((validate.command, validate.workflow_command), ("workflow", "validate")) - self.assertEqual(str(start.run_dir), "run") - self.assertEqual(str(status.run_dir), "run") - self.assertEqual(str(resume.snapshot), "snapshot.json") - - def test_workflow_validate_uses_default_service_root(self): - result = { - "schema": "agent-workflow/workflow-node-result/v1", - "workflow_id": "wf-1", - "action": "validate", - "result": {"snapshot_sha256": "abc", "node_count": 1}, - } - with ( - patch("agent_workflow.cli.WorkflowService.validate", return_value=result) as validate, - patch("agent_workflow.cli._print_json") as print_json, - patch("agent_workflow.cli._print_mapping") as print_mapping, - ): - self.assertEqual(main(["workflow", "validate", "snapshot.json", "--json"]), 0) - validate.assert_called_once() - self.assertEqual(str(validate.call_args.args[0]), "snapshot.json") - print_json.assert_called_once_with(result) - print_mapping.assert_not_called() - - def test_workflow_validate_human_output_uses_default_service_root(self): - result = { - "schema": "agent-workflow/workflow-node-result/v1", - "workflow_id": "wf-1", - "action": "validate", - "result": {"snapshot_sha256": "abc", "node_count": 1}, - } - with ( - patch("agent_workflow.cli.WorkflowService.validate", return_value=result) as validate, - patch("agent_workflow.cli._print_json") as print_json, - patch("agent_workflow.cli._print_mapping") as print_mapping, - ): - self.assertEqual(main(["workflow", "validate", "snapshot.json"]), 0) - validate.assert_called_once() - self.assertEqual(str(validate.call_args.args[0]), "snapshot.json") - print_mapping.assert_called_once_with(result) - print_json.assert_not_called() diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 379cc69..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,91 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.config import defaults, load_settings -from agent_workflow.errors import WorkflowError - - -class ConfigTests(unittest.TestCase): - def test_default_codex_command_uses_workspace_write_sandbox(self): - self.assertEqual( - defaults().executors["codex"], - [ - "codex", - "exec", - "--sandbox", - "workspace-write", - "--skip-git-repo-check", - "-", - ], - ) - self.assertTrue(defaults().mouse) - self.assertEqual(defaults().orchestrator_side, "left") - self.assertEqual(defaults().max_interactive_agent_width, 2) - self.assertEqual(defaults().max_interactive_agent_vertical, 3) - self.assertEqual(defaults().max_interactive_agent_panes, 6) - self.assertEqual(defaults().executor_policies["codex"].default_model, "gpt-5.4-mini") - self.assertEqual( - defaults().executor_policies["claude"].interactive_permission_args, - ("--permission-mode", "manual"), - ) - self.assertIn("haiku", defaults().executor_policies["claude"].models) - self.assertIn("fable", defaults().executor_policies["claude"].no_go_models) - self.assertFalse(defaults().agent_classes["exploratory"].interactive) - self.assertEqual( - defaults().agent_classes["exploratory"].allowed_models, - {"claude": ("haiku",), "codex": ("gpt-5.4-mini",)}, - ) - self.assertFalse(defaults().agent_classes["review"].interactive) - self.assertTrue(defaults().agent_classes["implementation"].interactive) - - def test_executor_and_paths_load(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - config = root / "config.toml" - config.write_text( - f'''[paths]\nstate_root = "{root / 'state'}"\nworktree_root = "{root / 'worktrees'}"\n\n[terminal]\nstall_minutes = 7\nmax_interactive_agent_width = 4\nmax_interactive_agent_vertical = 2\n\n[executors.test]\ncommand = ["cat"]\ninteractive_command = ["cat"]\nmodels = ["small", "large"]\ndefault_model = "small"\nno_go_models = ["large"]\nmodel_arg = ["--model"]\npermission_args = ["--safe"]\n''', - encoding="utf-8", - ) - settings = load_settings(config) - self.assertEqual(settings.stall_minutes, 7) - self.assertEqual(settings.max_interactive_agent_width, 4) - self.assertEqual(settings.max_interactive_agent_vertical, 2) - self.assertEqual(settings.max_interactive_agent_panes, 8) - self.assertEqual(settings.executors["test"], ["cat"]) - self.assertEqual(settings.executor_policies["test"].models, ("small", "large")) - self.assertEqual( - settings.executor_policies["test"].interactive_permission_args, - ("--safe",), - ) - self.assertEqual( - settings.executor_policies["test"].non_interactive_permission_args, - ("--safe",), - ) - self.assertEqual(settings.state_root, root / "state") - - def test_default_model_must_be_allowed(self): - with tempfile.TemporaryDirectory() as tmp: - config = Path(tmp) / "config.toml" - config.write_text( - '[executors.test]\ncommand=["cat"]\nmodels=["small"]\ndefault_model="large"\n', - encoding="utf-8", - ) - with self.assertRaisesRegex(WorkflowError, "must be listed"): - load_settings(config) - - def test_invalid_numeric_and_boolean_values_raise_workflow_error(self): - invalid_values = ( - ("[terminal]\nstall_minutes = \"many\"\n", "must be an integer"), - ("[terminal]\ncapture_lines = true\n", "must be an integer"), - ("[git]\nrequire_clean_source = \"false\"\n", "must be a boolean"), - ("[pack]\nwrite_sha256 = 1\n", "must be a boolean"), - ("[terminal]\nmax_interactive_agent_width = 0\n", "invalid stall_minutes"), - ) - with tempfile.TemporaryDirectory() as tmp: - config = Path(tmp) / "config.toml" - for content, message in invalid_values: - with self.subTest(content=content): - config.write_text(content, encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, message): - load_settings(config) diff --git a/tests/test_contracts.py b/tests/test_contracts.py deleted file mode 100644 index b8cd9a4..0000000 --- a/tests/test_contracts.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agent_workflow import contracts -from agent_workflow.contracts import load_schema, read_contract, validate_instance -from agent_workflow.errors import WorkflowError -from agent_workflow.migrations import migrate_contract -from agent_workflow.receipts import seal_run -from run_fixtures import write_run_contracts - - -class ContractTests(unittest.TestCase): - def test_user_base_schema_layout_seals_run_contracts(self) -> None: - """A user-site installation must discover its data-files schemas.""" - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - userbase = root / "userbase" - schema_root = userbase / "share" / "agent-workflow" / "schemas" - shutil.copytree(Path("schemas"), schema_root) - run = root / "run" - write_run_contracts(run, session_id="userbase-test") - - # Isolate discovery from this checkout and the active interpreter prefix. - with ( - patch.object( - contracts, - "__file__", - str(root / "isolated" / "agent_workflow" / "contracts.py"), - ), - patch.object(contracts.sys, "prefix", str(root / "prefix")), - patch.object(contracts.site, "getuserbase", return_value=str(userbase)), - ): - contracts._schema_index.cache_clear() - self.assertIn(schema_root, contracts._schema_roots()) - self.assertEqual( - contracts._schema_index()["agent-workflow/command/v1"], - schema_root / "command.schema.json", - ) - seal_run(run, session_id="userbase-test") - self.assertTrue((run / "final-receipt.json").is_file()) - contracts._schema_index.cache_clear() - - def test_packaged_schema_loads_and_validates_instance(self) -> None: - schema = load_schema("agent-workflow/evaluation-plan/v1") - self.assertEqual(schema["$id"], "agent-workflow/evaluation-plan/v1") - value = { - "schema": "agent-workflow/evaluation-plan/v1", - "dataset_split": "development", - "task_ids": ["P0-00"], - "repetitions": 1, - "timeout_seconds": 60, - "scorers": ["acceptance_commands"], - "sandbox": "docker", - } - validate_instance(value, value["schema"]) - with self.assertRaisesRegex(WorkflowError, "repetitions"): - validate_instance({**value, "repetitions": 0}, value["schema"]) - - def test_read_contract_rejects_wrong_schema(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "value.json" - path.write_text(json.dumps({"schema": "unknown/v1"}), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "unexpected contract schema"): - read_contract(path, "agent-workflow/evaluation-plan/v1") - - def test_status_v1_migrates_in_memory(self) -> None: - source = { - "schema": "agent-workflow/session-status/v1", - "status": "accepted", - "session_id": "sample", - } - migrated = migrate_contract(source, "agent-workflow/session-status/v2") - self.assertEqual(source["status"], "accepted") - self.assertEqual(migrated["status"], "completed") - self.assertEqual(migrated["disposition"], "accepted") - self.assertEqual(migrated["schema"], "agent-workflow/session-status/v2") diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py deleted file mode 100644 index c881fa8..0000000 --- a/tests/test_diagnostics.py +++ /dev/null @@ -1,20 +0,0 @@ -import unittest - -from agent_workflow.diagnostics import classify_failure - - -class FailureTaxonomyTests(unittest.TestCase): - def test_known_failures_are_stable_and_unknown_stays_unclassified(self): - cases = { - "permission denied": "permission_denied", - "HTTP 429 rate limit": "rate_limited", - "API key unauthorized": "authentication", - "executable not found": "command_not_found", - "surprising failure": "unclassified", - } - for text, expected in cases.items(): - with self.subTest(text=text): - self.assertEqual( - classify_failure(exit_code=1, stderr=text), expected - ) - self.assertIsNone(classify_failure(exit_code=0)) diff --git a/tests/test_doctor.py b/tests/test_doctor.py deleted file mode 100644 index 60993b8..0000000 --- a/tests/test_doctor.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -import subprocess -import unittest -from unittest.mock import patch - -from agent_workflow.doctor import _archive_commands_supported - - -class DoctorTests(unittest.TestCase): - @patch("agent_workflow.doctor.subprocess.run") - def test_archive_ready_requires_deterministic_tar_options(self, run) -> None: - run.return_value = subprocess.CompletedProcess( - ["tar", "--help"], - 0, - stdout="--sort --mtime --owner --group --numeric-owner", - stderr="", - ) - self.assertTrue( - _archive_commands_supported( - {"tar": "/usr/bin/tar", "zstd": "/usr/bin/zstd"} - ) - ) - - run.return_value = subprocess.CompletedProcess( - ["tar", "--help"], 0, stdout="plain tar", stderr="" - ) - self.assertFalse( - _archive_commands_supported( - {"tar": "/usr/bin/tar", "zstd": "/usr/bin/zstd"} - ) - ) - self.assertFalse( - _archive_commands_supported({"tar": "/usr/bin/tar", "zstd": None}) - ) diff --git a/tests/test_eval_commands.py b/tests/test_eval_commands.py deleted file mode 100644 index abfe266..0000000 --- a/tests/test_eval_commands.py +++ /dev/null @@ -1,50 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.eval.commands import CommandSpec, collect_commands -from agent_workflow.eval.junit import compare_junit, parse_junit - - -class CommandCollectorTests(unittest.TestCase): - def test_collects_exit_and_junit_receipts(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - junit = root / "junit.xml" - junit.write_text( - '', - encoding="utf-8", - ) - collection = collect_commands( - root, - [ - CommandSpec("unit", ("python3", "-c", "print('ok')")), - CommandSpec( - "junit", - ("python3", "-c", "pass"), - result_format="junit", - junit_path="junit.xml", - ), - ], - phase="post", - receipt_dir=root / "receipts", - ) - self.assertEqual([item["exit_code"] for item in collection["commands"]], [0, 0]) - self.assertEqual(collection["commands"][1]["junit"]["tests"], {"a::ok": "pass"}) - - def test_junit_regressions_only_attribute_pass_to_failure(self): - baseline = {"a::ok": "pass", "a::broken": "fail"} - post = {"a::ok": "fail", "a::broken": "pass"} - result = compare_junit(baseline, post) - self.assertEqual(result["regressions"], ["a::ok"]) - self.assertEqual(result["fixes"], ["a::broken"]) - - def test_parse_junit_rejects_duplicate_ids(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "junit.xml" - path.write_text( - '', - encoding="utf-8", - ) - with self.assertRaisesRegex(Exception, "duplicate JUnit"): - parse_junit(path) diff --git a/tests/test_eval_compare.py b/tests/test_eval_compare.py deleted file mode 100644 index 5591c48..0000000 --- a/tests/test_eval_compare.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest - -from agent_workflow.errors import WorkflowError -from agent_workflow.eval.compare import ComparisonPolicy, compare_trials - - -def trial(repetition: int, verdict: str, prompt: str = "p") -> dict[str, object]: - return { - "fixture_revision": "f", - "task_id": "t", - "base_revision": "b", - "prompt_sha256": prompt, - "oracle_sha256": "o", - "acceptance_commands_sha256": "a", - "scope_policy_sha256": "s", - "scorer_versions_sha256": "v", - "sandbox": "docker", - "budget_sha256": "budget", - "repetition": repetition, - "verdict": verdict, - } - - -class ComparisonTests(unittest.TestCase): - def test_single_trial_never_declares_winner_or_tail_metrics(self): - value = compare_trials([trial(0, "fail")], [trial(0, "pass")]) - self.assertIsNone(value["winner"]) - self.assertEqual(value["paired_n"], 1) - self.assertFalse(value["tail_metrics_eligible"]["p90"]) - - def test_mismatched_cohort_fails_closed(self): - with self.assertRaisesRegex(WorkflowError, "cohorts do not match"): - compare_trials([trial(0, "pass")], [trial(0, "pass", prompt="different")]) - value = compare_trials( - [trial(0, "pass")], - [trial(0, "pass", prompt="different")], - policy=ComparisonPolicy(allow_unpaired=True), - ) - self.assertTrue(value["descriptive_only"]) - self.assertIsNone(value["winner"]) - - def test_unpaired_analysis_never_declares_winner(self): - baseline = [trial(index, "fail") for index in range(10)] - candidate = [trial(index, "pass") for index in range(10)] - candidate.append(trial(99, "pass")) - value = compare_trials( - baseline, - candidate, - policy=ComparisonPolicy(allow_unpaired=True), - ) - self.assertTrue(value["descriptive_only"]) - self.assertIsNone(value["winner"]) diff --git a/tests/test_eval_oracles.py b/tests/test_eval_oracles.py deleted file mode 100644 index eaabfb6..0000000 --- a/tests/test_eval_oracles.py +++ /dev/null @@ -1,29 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.eval.oracles import resolve_oracle, scan_for_leak -from agent_workflow.util import sha256_file - - -class OracleBoundaryTests(unittest.TestCase): - def test_oracle_is_resolved_by_id_and_hash_outside_worktree(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - oracle = root / "oracles" / "task-1" - oracle.mkdir(parents=True) - manifest = oracle / "oracle.json" - manifest.write_text('{"expected": "pass"}\n', encoding="utf-8") - value = resolve_oracle("task-1", sha256_file(manifest), root / "oracles") - self.assertEqual(value.oracle_id, "task-1") - with self.assertRaisesRegex(WorkflowError, "escapes"): - resolve_oracle("../escape", "0" * 64, root / "oracles") - - def test_canary_scan_detects_leak(self): - with tempfile.TemporaryDirectory() as tmp: - artifact = Path(tmp) / "output.log" - artifact.write_bytes(b"prefix evaluator-secret suffix") - self.assertEqual( - scan_for_leak(b"evaluator-secret", [artifact]), [str(artifact)] - ) diff --git a/tests/test_eval_reporting.py b/tests/test_eval_reporting.py deleted file mode 100644 index 44eb9db..0000000 --- a/tests/test_eval_reporting.py +++ /dev/null @@ -1,84 +0,0 @@ -import tempfile -import unittest -import hashlib -import json -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.eval.reporting import build_report, render_markdown -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json, sha256_file -from run_fixtures import write_run_contracts - - -class EvaluationReportTests(unittest.TestCase): - def test_report_reads_only_sealed_local_evidence(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="report-test") - seal_run(run, session_id="report-test") - final_hash = sha256_file(run / "final-receipt.json") - scores = run / "scores" - scores.mkdir() - score_receipt = { - "schema": "agent-workflow/score-receipt/v1", - "scorer": {"id": "schema_validity", "version": "1"}, - "final_receipt_sha256": final_hash, - "verdict": "pass", - "facts": {}, - "evidence": [], - } - digest = hashlib.sha256( - json.dumps( - score_receipt, sort_keys=True, separators=(",", ":") - ).encode() - ).hexdigest() - atomic_write_json(scores / f"schema_validity-{digest}.json", score_receipt, mode=0o444) - atomic_write_json( - scores / "score-set.json", - { - "final_receipt_sha256": final_hash, - "verdict": "pass", - "scores": [score_receipt], - }, - ) - value = build_report( - run, - expected_final_receipt_sha256=final_hash, - ) - self.assertEqual(value["score_verdict"], "pass") - self.assertIn("report-test", render_markdown(value)) - - def test_report_rejects_forged_unsealed_evidence(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="forged-score") - seal_run(run, session_id="forged-score") - final_hash = sha256_file(run / "final-receipt.json") - scores = run / "scores" - scores.mkdir() - receipt = { - "schema": "agent-workflow/score-receipt/v1", - "scorer": {"id": "schema_validity", "version": "1"}, - "final_receipt_sha256": final_hash, - "verdict": "pass", - "facts": {}, - "evidence": [{"path": "not-sealed.txt", "sha256": "0" * 64}], - } - digest = hashlib.sha256( - json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - atomic_write_json(scores / f"schema_validity-{digest}.json", receipt, mode=0o444) - atomic_write_json( - scores / "score-set.json", - { - "final_receipt_sha256": final_hash, - "verdict": "pass", - "scores": [receipt], - }, - ) - with self.assertRaisesRegex(WorkflowError, "absent from .*final"): - build_report( - run, - expected_final_receipt_sha256=final_hash, - ) diff --git a/tests/test_eval_runtime.py b/tests/test_eval_runtime.py deleted file mode 100644 index 1f19ac1..0000000 --- a/tests/test_eval_runtime.py +++ /dev/null @@ -1,83 +0,0 @@ -import json -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.config import defaults -from agent_workflow.eval.scoring import score_trial -from agent_workflow.receipts import verify_seal -from agent_workflow.sessions import launch - - -class EvaluationRuntimeTests(unittest.TestCase): - def test_collectors_run_before_seal_and_rescore_without_agent(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - work = root / "work" - work.mkdir() - (work / "answer.txt").write_text("before\n", encoding="utf-8") - prompt = root / "prompt.md" - prompt.write_text("update answer\n", encoding="utf-8") - evaluation = root / "evaluation.json" - evaluation.write_text( - json.dumps( - { - "schema": "agent-workflow/evaluation-plan/v1", - "dataset_split": "development", - "task_ids": ["P0-01"], - "repetitions": 1, - "timeout_seconds": 60, - "scorers": ["acceptance_commands", "writable_scope"], - "sandbox": "docker", - "acceptance_commands": [ - { - "id": "answer", - "argv": [ - "python3", - "-c", - "from pathlib import Path; assert Path('answer.txt').is_file()", - ], - } - ], - "scope": {"writable_paths": ["answer.txt"]}, - } - ), - encoding="utf-8", - ) - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - receiver = ( - "from pathlib import Path; import sys; " - "sys.stdin.read(); Path('answer.txt').write_text('after\\n')" - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value=None), - patch("agent_workflow.sessions.tmux.create_session") as create, - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - ): - launched = launch( - settings, - session_id="eval-runtime", - workdir=work, - prompt_path=prompt, - explicit_command=["python3", "-c", receiver], - ticket_id="P0-01", - evaluation_path=evaluation, - ) - subprocess.run([create.call_args.args[2]], check=True) - run = Path(launched["prompt_path"]).parent - status = json.loads((run / "status.json").read_text(encoding="utf-8")) - verify_seal(run, expected_sha256=status["final_receipt_sha256"]) - self.assertTrue((run / "scope" / "scope-post.json").is_file()) - self.assertTrue((run / "collections" / "commands-post.json").is_file()) - result = score_trial( - run, - output_dir=run / "scores", - expected_final_receipt_sha256=status["final_receipt_sha256"], - ) - self.assertEqual(result["verdict"], "pass") diff --git a/tests/test_eval_scope.py b/tests/test_eval_scope.py deleted file mode 100644 index 58bd7fa..0000000 --- a/tests/test_eval_scope.py +++ /dev/null @@ -1,74 +0,0 @@ -import subprocess -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.eval.scope import ScopePolicy, collect_scope, compare_scope - - -class ScopeCollectorTests(unittest.TestCase): - def test_detects_ignored_mutation_and_nested_repository(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - repo = root / "repo" - receipts = root / "receipts" - repo.mkdir() - subprocess.run(["git", "init", "-q", str(repo)], check=True) - subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True) - subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.test"], check=True) - (repo / ".gitignore").write_text("private.txt\n", encoding="utf-8") - (repo / "tracked.txt").write_text("base\n", encoding="utf-8") - (repo / "private.txt").write_text("secret\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "add", ".gitignore", "tracked.txt"], check=True) - subprocess.run(["git", "-C", str(repo), "commit", "-qm", "base"], check=True) - policy = ScopePolicy(repo, writable_paths=("tracked.txt",)) - - baseline = collect_scope(repo, phase="baseline", policy=policy, receipt_dir=receipts) - (repo / "tracked.txt").write_text("allowed\n", encoding="utf-8") - (repo / "private.txt").write_text("changed\n", encoding="utf-8") - nested = repo / "nested" - nested.mkdir() - subprocess.run(["git", "init", "-q", str(nested)], check=True) - post = collect_scope(repo, phase="post", policy=policy, receipt_dir=receipts) - result = compare_scope(baseline, post, policy) - - self.assertIn("private.txt", result["violations"]) - self.assertIn("nested/.git", result["violations"]) - self.assertNotIn("tracked.txt", result["violations"]) - self.assertIn("private.txt", baseline["repositories"][0]["ignored"]) - - def test_disposable_tree_is_explicitly_recorded(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "cache").mkdir() - (root / "cache" / "value").write_text("x", encoding="utf-8") - policy = ScopePolicy(root, disposable_trees=("cache/",)) - result = collect_scope(root, phase="baseline", policy=policy, receipt_dir=root.parent / "receipts") - self.assertEqual(result["inventory"], []) - self.assertEqual(result["excluded"], ["cache/"]) - - def test_commits_since_baseline_and_escaping_symlinks_are_visible(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - repo = root / "repo" - receipts = root / "receipts" - repo.mkdir() - subprocess.run(["git", "init", "-q", str(repo)], check=True) - subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True) - subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.test"], check=True) - tracked = repo / "tracked.txt" - tracked.write_text("base\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "add", "tracked.txt"], check=True) - subprocess.run(["git", "-C", str(repo), "commit", "-qm", "base"], check=True) - policy = ScopePolicy(repo, writable_paths=("tracked.txt",)) - baseline = collect_scope(repo, phase="baseline", policy=policy, receipt_dir=receipts) - tracked.write_text("committed\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "commit", "-qam", "agent"], check=True) - outside = root / "outside.txt" - outside.write_text("outside\n", encoding="utf-8") - (repo / "escape").symlink_to(outside) - post = collect_scope(repo, phase="post", policy=policy, receipt_dir=receipts) - result = compare_scope(baseline, post, policy) - - self.assertIn("tracked.txt", post["repositories"][0]["committed"]) - self.assertIn("escape", result["violations"]) diff --git a/tests/test_eval_scoring.py b/tests/test_eval_scoring.py deleted file mode 100644 index 6a16b09..0000000 --- a/tests/test_eval_scoring.py +++ /dev/null @@ -1,84 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.eval.scoring import score_trial -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json -from agent_workflow.util import sha256_file -from run_fixtures import write_run_contracts - - -class DeterministicScoringTests(unittest.TestCase): - def test_scores_sealed_commands_and_is_byte_stable(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) / "run" - output = Path(tmp) / "scores" - collections = root / "collections" - root.mkdir() - collections.mkdir() - write_run_contracts(root, session_id="score-test") - atomic_write_json( - root / "evaluation-runtime.json", - { - "schema": "agent-workflow/evaluation-runtime/v1", - "scorers": ["acceptance_commands", "evidence_fidelity"], - }, - ) - atomic_write_json( - collections / "commands-post.json", - { - "schema": "agent-workflow/command-collection-set/v1", - "phase": "post", - "commands": [ - { - "id": "unit", - "argv": ["python3", "-m", "pytest"], - "cwd": ".", - "exit_code": 0, - "timed_out": False, - } - ], - }, - ) - seal_run(root, session_id="score-test") - expected = sha256_file(root / "final-receipt.json") - - first = score_trial( - root, - output_dir=output, - expected_final_receipt_sha256=expected, - ) - initial_files = {path.name: path.read_bytes() for path in output.iterdir()} - second = score_trial( - root, - output_dir=output, - expected_final_receipt_sha256=expected, - ) - final_files = {path.name: path.read_bytes() for path in output.iterdir()} - - self.assertEqual(first, second) - self.assertEqual(first["verdict"], "pass") - self.assertEqual(initial_files, final_files) - - def test_missing_required_collectors_is_invalid(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) / "run" - write_run_contracts(root, session_id="missing-collectors") - seal_run(root, session_id="missing-collectors") - result = score_trial( - root, - output_dir=Path(tmp) / "scores", - expected_final_receipt_sha256=sha256_file( - root / "final-receipt.json" - ), - ) - self.assertEqual(result["verdict"], "invalid") - self.assertIn( - "acceptance_commands", - { - score["scorer"]["id"] - for score in result["scores"] - if score["verdict"] == "invalid" - }, - ) diff --git a/tests/test_eval_trials.py b/tests/test_eval_trials.py deleted file mode 100644 index 900b19b..0000000 --- a/tests/test_eval_trials.py +++ /dev/null @@ -1,97 +0,0 @@ -import hashlib -import json -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.eval.trials import collect_trials, extract_trial, load_trials -from agent_workflow.metrics import write_execution_evidence -from agent_workflow.provider_evidence import write_provider_evidence -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json -from run_fixtures import write_run_contracts - - -class TrialEvidenceTests(unittest.TestCase): - def _run(self, root: Path, name: str) -> Path: - run = root / name - write_run_contracts(run, session_id=name) - (run / "executor-events.jsonl").write_text( - '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2,"provider_billed_cost":0.1,"currency":"USD"}}\n', - encoding="utf-8", - ) - provider = write_provider_evidence(run, stream_format="codex-jsonl", executor="codex") - provenance = __import__("json").loads((run / "run-provenance.json").read_text()) - provenance["usage"] = provider["aggregate"] - provenance["provider_evidence"] = { - "path": "provider-evidence.json", - "sha256": __import__("hashlib").sha256((run / "provider-evidence.json").read_bytes()).hexdigest(), - "usage_complete": True, - "capture_complete": True, - } - atomic_write_json(run / "run-provenance.json", provenance) - write_execution_evidence(run, elapsed_seconds=1.5) - seal_run(run, session_id=name) - final_hash = hashlib.sha256((run / "final-receipt.json").read_bytes()).hexdigest() - score = { - "schema": "agent-workflow/score-receipt/v1", - "scorer": {"id": "schema_validity", "version": "1"}, - "final_receipt_sha256": final_hash, - "verdict": "pass", - "facts": {"contracts": ["completion", "provenance"]}, - "evidence": [ - {"path": "completion.json", "sha256": hashlib.sha256((run / "completion.json").read_bytes()).hexdigest()}, - {"path": "run-provenance.json", "sha256": hashlib.sha256((run / "run-provenance.json").read_bytes()).hexdigest()}, - ], - } - encoded = json.dumps(score, sort_keys=True, separators=(",", ":")).encode() - digest = hashlib.sha256(encoded).hexdigest() - atomic_write_json(run / "scores" / f"schema_validity-{digest}.json", score, mode=0o444) - atomic_write_json( - run / "scores" / "score-set.json", - { - "schema": "agent-workflow/score-set/v1", - "final_receipt_sha256": final_hash, - "verdict": "pass", - "scores": [score], - }, - ) - return run - - def test_extract_and_collect_explicit_sealed_trials(self): - with tempfile.TemporaryDirectory() as tmp: - run = self._run(Path(tmp), "trial-1") - trial = extract_trial(run) - self.assertEqual(5, trial["tokens"]) - self.assertEqual("pass", trial["verdict"]) - self.assertEqual(0.1, trial["provider_billed_cost"]) - self.assertIsNone(trial["local_estimated_cost"]) - output = Path(tmp) / "evidence.json" - collect_trials([run], output) - self.assertEqual([trial], load_trials(output)) - - def test_unsealed_run_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, include_final=False) - with self.assertRaisesRegex(Exception, "final receipt"): - extract_trial(run) - def test_writable_content_addressed_score_receipt_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - run = self._run(Path(tmp), "trial-writable-score") - score_file = next( - path for path in (run / "scores").glob("schema_validity-*.json") - ) - score_file.chmod(0o644) - with self.assertRaisesRegex(Exception, "read-only"): - extract_trial(run) - - def test_forged_score_set_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - run = self._run(Path(tmp), "trial-forged") - atomic_write_json( - run / "scores" / "score-set.json", - {"verdict": "pass", "scores": []}, - ) - with self.assertRaisesRegex(Exception, "score set"): - extract_trial(run) diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py deleted file mode 100644 index af4e77a..0000000 --- a/tests/test_evaluation.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import json -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.evaluation import validate_evaluation - - -class EvaluationPlanTests(unittest.TestCase): - def _write(self, root: Path, **changes) -> Path: - value = { - "schema": "agent-workflow/evaluation-plan/v1", - "dataset_split": "validation", - "task_ids": ["P0-00"], - "repetitions": 3, - "timeout_seconds": 300, - "max_retries": 0, - "scorers": ["acceptance_commands", "writable_scope"], - "sandbox": "docker", - } - value.update(changes) - path = root / "evals" / "evaluation.json" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(value), encoding="utf-8") - return path - - def test_valid_plan_has_stable_hash(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - path = self._write(root) - first = validate_evaluation(path, pack_root=root, task_ids={"P0-00"}) - path.write_text( - json.dumps(first.data, indent=2, sort_keys=True), encoding="utf-8" - ) - second = validate_evaluation(path, pack_root=root, task_ids={"P0-00"}) - self.assertEqual(first.sha256, second.sha256) - - def test_unknown_task_and_oracle_are_rejected(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - path = self._write(root, task_ids=["P9-99"]) - with self.assertRaisesRegex(WorkflowError, "unknown tasks"): - validate_evaluation(path, pack_root=root, task_ids={"P0-00"}) - path = self._write( - root, - oracle_refs={"P9-99": {"id": "secret", "sha256": "0" * 64}}, - ) - with self.assertRaisesRegex(WorkflowError, "oracle_refs"): - validate_evaluation(path, pack_root=root, task_ids={"P0-00"}) - - def test_plan_escape_and_malformed_instance_are_rejected(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) / "pack" - root.mkdir() - outside = self._write(Path(tmp), repetitions=0) - with self.assertRaisesRegex(WorkflowError, "escapes pack root"): - validate_evaluation(outside, pack_root=root) - inside = self._write(root, repetitions=0) - with self.assertRaisesRegex(WorkflowError, "repetitions"): - validate_evaluation(inside, pack_root=root) diff --git a/tests/test_executor_events.py b/tests/test_executor_events.py deleted file mode 100644 index b67e17f..0000000 --- a/tests/test_executor_events.py +++ /dev/null @@ -1,94 +0,0 @@ -import unittest - -from agent_workflow.config import defaults -from agent_workflow.executors import accumulate_usage, event_text, event_usage, prepare_executor - - -class ExecutorEventTests(unittest.TestCase): - def test_known_executors_enable_structured_streams(self): - settings = defaults() - codex = prepare_executor(settings, "codex", None, structured=True) - claude = prepare_executor(settings, "claude", None, structured=True) - - self.assertEqual(codex.stream_format, "codex-jsonl") - self.assertIn("--json", codex.argv) - self.assertIn("gpt-5.4-mini", codex.argv) - self.assertNotIn("--ask-for-approval", codex.argv) - self.assertEqual(claude.stream_format, "claude-stream-json") - self.assertIn("--verbose", claude.argv) - self.assertIn("manual", claude.argv) - self.assertEqual(claude.argv[-2:], ("--output-format", "stream-json")) - - def test_event_adapters_extract_text_and_usage(self): - codex = { - "item": {"type": "agent_message", "text": "done"}, - "usage": {"input_tokens": 3, "output_tokens": 2}, - } - claude = { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "ok"}]}, - } - - self.assertEqual(event_text(codex, "codex-jsonl"), ["done"]) - self.assertEqual(event_usage(codex), codex["usage"]) - self.assertEqual(event_text(claude, "claude-stream-json"), ["ok"]) - - def test_unknown_explicit_commands_remain_unmodified(self): - plan = prepare_executor(defaults(), None, ["cat"], structured=True) - self.assertEqual(plan.name, None) - self.assertEqual(plan.argv, ("cat",)) - self.assertEqual(plan.stream_format, "text") - - def test_interactive_known_executors_use_the_tui_entrypoint(self): - codex = prepare_executor(defaults(), "codex", None, interactive=True) - claude = prepare_executor(defaults(), "claude", None, interactive=True) - self.assertEqual(codex.argv, ("codex", "--ask-for-approval", "on-request", "--model", "gpt-5.4-mini")) - self.assertEqual(claude.argv, ("claude", "--permission-mode", "manual", "--model", "sonnet")) - with self.assertRaisesRegex(Exception, "mutually exclusive"): - prepare_executor(defaults(), "claude", None, structured=True, interactive=True) - - def test_non_interactive_permissions_use_subcommand_specific_flags(self): - codex = prepare_executor(defaults(), "codex", None) - claude = prepare_executor(defaults(), "claude", None) - self.assertNotIn("--ask-for-approval", codex.argv) - self.assertIn("--permission-mode", claude.argv) - - def test_no_go_models_require_explicit_recorded_authorization(self): - with self.assertRaisesRegex(Exception, "no-go"): - prepare_executor(defaults(), "codex", None, model="gpt-5.6-sol") - plan = prepare_executor( - defaults(), "codex", None, model="gpt-5.6-sol", allow_no_go_model=True - ) - self.assertEqual(plan.model, "gpt-5.6-sol") - self.assertTrue(plan.no_go_authorized) - with self.assertRaisesRegex(Exception, "no-go"): - prepare_executor(defaults(), "claude", None, model="fable") - - def test_executor_rejects_models_not_in_its_allowlist(self): - with self.assertRaisesRegex(Exception, "not allowed"): - prepare_executor(defaults(), "claude", None, model="gpt-5.4-mini") - - def test_explicit_usage_modes_do_not_double_count_terminal_total(self): - usage = accumulate_usage(None, {"input_tokens": 3, "output_tokens": 2}, mode="delta") - usage = accumulate_usage(usage, {"input_tokens": 4, "output_tokens": 5}, mode="delta") - usage = accumulate_usage(usage, {"input_tokens": 7, "output_tokens": 7}, mode="terminal") - self.assertEqual(usage["input_tokens"], 7) - self.assertEqual(usage["output_tokens"], 7) - - def test_explicit_known_executors_preserve_structured_format(self): - codex = prepare_executor( - defaults(), None, ["/usr/local/bin/codex", "exec", "-"], structured=True - ) - claude = prepare_executor( - defaults(), None, ["claude", "--print"], structured=True - ) - - self.assertEqual(codex.name, "codex") - self.assertEqual(codex.argv, ("/usr/local/bin/codex", "exec", "--model", "gpt-5.4-mini", "--json", "-")) - self.assertEqual(codex.stream_format, "codex-jsonl") - self.assertEqual(claude.name, "claude") - self.assertEqual( - claude.argv, - ("claude", "--print", "--model", "sonnet", "--verbose", "--output-format", "stream-json"), - ) - self.assertEqual(claude.stream_format, "claude-stream-json") diff --git a/tests/test_inspect_adapter.py b/tests/test_inspect_adapter.py deleted file mode 100644 index e815afa..0000000 --- a/tests/test_inspect_adapter.py +++ /dev/null @@ -1,38 +0,0 @@ -import subprocess -import sys -import unittest -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.inspect_adapter import _load_inspect_api - - -ROOT = Path(__file__).resolve().parents[1] - - -class InspectAdapterTests(unittest.TestCase): - def test_module_import_does_not_import_optional_packages(self): - code = f""" -import builtins -import sys -sys.path.insert(0, {str(ROOT / 'src')!r}) -real_import = builtins.__import__ -def deny(name, *args, **kwargs): - if name.split('.', 1)[0] in {{'inspect_ai', 'inspect_swe'}}: - raise AssertionError('optional Inspect package imported eagerly') - return real_import(name, *args, **kwargs) -builtins.__import__ = deny -import agent_workflow.inspect_adapter -""" - subprocess.run( - [sys.executable, "-I", "-c", code], - check=True, - capture_output=True, - text=True, - ) - - def test_missing_extra_has_actionable_error(self): - try: - _load_inspect_api() - except WorkflowError as exc: - self.assertIn("agent-workflow[eval]", str(exc)) diff --git a/tests/test_install_uninstall.py b/tests/test_install_uninstall.py deleted file mode 100644 index 9c2eb74..0000000 --- a/tests/test_install_uninstall.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - -import jsonschema - -ROOT = Path(__file__).resolve().parents[1] - - -class InstallUninstallTests(unittest.TestCase): - def _run(self, script: str, home: Path, *args: str) -> subprocess.CompletedProcess[str]: - dependency_root = str(Path(jsonschema.__file__).resolve().parents[1]) - inherited = os.environ.get("PYTHONPATH") - pythonpath = dependency_root + (os.pathsep + inherited if inherited else "") - env = {**os.environ, "HOME": str(home), "PYTHONPATH": pythonpath} - return subprocess.run( - [str(ROOT / script), *args], - cwd=ROOT, - env=env, - text=True, - capture_output=True, - check=False, - ) - - def test_install_and_uninstall_owned_links(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - home = Path(tmp) - installed = self._run("install.sh", home, "--no-deps") - self.assertEqual(installed.returncode, 0, installed.stderr) - launcher = home / ".local/bin/agent-workflow" - self.assertEqual(launcher.resolve(), (ROOT / "bin/agent-workflow").resolve()) - for root in ( - home / ".agents/skills", - home / ".codex/skills", - home / ".claude/skills", - ): - for skill in ( - "agent-workflow-orchestrator", - "delegated-implementation", - "prompt-pack-builder", - "phase-gate-review", - ): - self.assertEqual((root / skill).resolve(), (ROOT / "skills" / skill).resolve()) - - removed = self._run("uninstall.sh", home) - self.assertEqual(removed.returncode, 0, removed.stderr) - self.assertFalse(launcher.exists()) - self.assertFalse(any((home / ".agents/skills").iterdir())) - self.assertFalse(any((home / ".codex/skills").iterdir())) - self.assertFalse(any((home / ".claude/skills").iterdir())) - - def test_install_is_idempotent_and_refuses_unrelated_skill(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - home = Path(tmp) - first = self._run("install.sh", home, "--no-deps") - self.assertEqual(first.returncode, 0, first.stderr) - second = self._run("install.sh", home, "--no-deps") - self.assertEqual(second.returncode, 0, second.stderr) - - target = home / ".codex/skills/agent-workflow-orchestrator" - target.unlink() - target.write_text("user-owned\n", encoding="utf-8") - refused = self._run("install.sh", home, "--no-deps") - self.assertEqual(refused.returncode, 2) - self.assertIn("refusing to replace non-symlink path", refused.stderr) - self.assertEqual(target.read_text(encoding="utf-8"), "user-owned\n") - - def test_uninstall_preserves_unrelated_paths(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - home = Path(tmp) - launcher = home / ".local/bin/agent-workflow" - launcher.parent.mkdir(parents=True) - launcher.write_text("user-owned\n", encoding="utf-8") - skill = home / ".agents/skills/delegated-implementation" - skill.parent.mkdir(parents=True) - skill.symlink_to(home / "unrelated-skill") - - result = self._run("uninstall.sh", home) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(launcher.read_text(encoding="utf-8"), "user-owned\n") - self.assertTrue(skill.is_symlink()) - self.assertIn("preserved unrelated path", result.stderr) diff --git a/tests/test_ledger.py b/tests/test_ledger.py deleted file mode 100644 index 39c28a0..0000000 --- a/tests/test_ledger.py +++ /dev/null @@ -1,38 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.ledger import build_ledger, render_ledger - - -class LedgerTests(unittest.TestCase): - def test_missing_and_corrupt_sessions_remain_visible(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - pack = root / "pack" - phase = pack / "phase-0" - phase.mkdir(parents=True) - (phase / "task-manifest.yaml").write_text( - "tasks:\n" - " - id: P0-01\n" - " tier: low\n" - " session: first\n" - " prompt: tickets/one.md\n" - " - id: P0-02\n" - " tier: low\n" - " session: second\n" - " prompt: tickets/two.md\n", - encoding="utf-8", - ) - runs = root / "runs" - (runs / "second").mkdir(parents=True) - (runs / "second" / "status.json").write_text("{", encoding="utf-8") - value = build_ledger(pack, runs) - self.assertEqual(len(value["rows"]), 2) - self.assertEqual(value["rows"][0]["status"], "missing") - self.assertIn("invalid status", value["rows"][1]["error"]) - self.assertEqual( - value["rows"][1]["next_action"], - "agent-workflow status second --json", - ) - self.assertIn("P0-01", render_ledger(value)) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py deleted file mode 100644 index 3fdd859..0000000 --- a/tests/test_lifecycle.py +++ /dev/null @@ -1,247 +0,0 @@ -import json -import hashlib -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.config import defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.events import reconstruct_lifecycle -from agent_workflow.lifecycle import record -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json, sha256_file -from run_fixtures import write_run_contracts - - -class LifecycleTests(unittest.TestCase): - def _completed_run( - self, - root: Path, - *, - tier: str = "medium", - executor: str | None = None, - ) -> tuple[object, Path]: - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - run = root / "state" / "runs" / "life-test" - write_run_contracts(run, session_id="life-test") - completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) - completion.update(result="completed", head_revision="abc123", unresolved=[]) - atomic_write_json(run / "completion.json", completion) - atomic_write_json( - run / "evaluation-runtime.json", - { - "schema": "agent-workflow/evaluation-runtime/v1", - "scorers": ["acceptance_commands"], - }, - ) - status = json.loads((run / "status.json").read_text(encoding="utf-8")) - status.update( - status="completed", disposition=None, tier=tier, executor=executor - ) - atomic_write_json(run / "status.json", status) - atomic_write_json(run / "final-status.json", status) - seal_run(run, session_id="life-test") - status.update( - final_receipt_path=str(run / "final-receipt.json"), - final_receipt_sha256=sha256_file(run / "final-receipt.json"), - ) - atomic_write_json(run / "status.json", status) - scores = run / "scores" - scores.mkdir() - score_receipts = [] - for scorer_id in ("schema_validity", "acceptance_commands"): - score_receipt = { - "schema": "agent-workflow/score-receipt/v1", - "scorer": {"id": scorer_id, "version": "1"}, - "final_receipt_sha256": status["final_receipt_sha256"], - "verdict": "pass", - "facts": {}, - "evidence": [], - } - encoded = json.dumps( - score_receipt, sort_keys=True, separators=(",", ":") - ).encode() - digest = hashlib.sha256(encoded).hexdigest() - atomic_write_json(scores / f"{scorer_id}-{digest}.json", score_receipt, mode=0o444) - score_receipts.append(score_receipt) - atomic_write_json( - scores / "score-set.json", - { - "schema": "agent-workflow/score-set/v1", - "final_receipt_sha256": status["final_receipt_sha256"], - "verdict": "pass", - "scores": score_receipts, - }, - ) - return settings, run - - def test_review_then_accept_appends_receipts(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run(Path(tmp)) - reviewed = record( - settings, - "life-test", - action="reviewed", - actor="reviewer", - reason="deterministic gates pass", - ) - accepted = record( - settings, - "life-test", - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - self.assertEqual(reviewed["disposition"], "reviewed") - self.assertEqual(accepted["disposition"], "accepted") - self.assertEqual(len(list((run / "receipts").glob("*.json"))), 2) - reconstructed = reconstruct_lifecycle(run / "events.jsonl") - self.assertEqual(reconstructed["state"]["review"], "accepted") - - def test_mutable_status_projection_cannot_change_review_authority(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run(Path(tmp)) - status_path = run / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status.update( - status="failed", - tier="critical", - executor="reviewer", - final_receipt_sha256="0" * 64, - ) - atomic_write_json(status_path, status) - reviewed = record( - settings, - "life-test", - action="reviewed", - actor="reviewer", - reason="canonical terminal evidence controls", - ) - receipt = json.loads( - Path(reviewed["lifecycle_receipt"]).read_text(encoding="utf-8") - ) - self.assertTrue(receipt["reviewer_independent"]) - accepted = record( - settings, - "life-test", - action="accepted", - actor="reviewer", - reason="projection tamper ignored", - revision="abc123", - ) - self.assertEqual("accepted", accepted["disposition"]) - - def test_accept_rejects_revision_mismatch_and_failed_score(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run(Path(tmp)) - record( - settings, - "life-test", - action="reviewed", - actor="reviewer", - reason="reviewed", - ) - with self.assertRaisesRegex(WorkflowError, "revision mismatch"): - record( - settings, - "life-test", - action="accepted", - actor="reviewer", - reason="wrong revision", - revision="wrong", - ) - score_path = run / "scores" / "score-set.json" - score = json.loads(score_path.read_text(encoding="utf-8")) - score["verdict"] = "fail" - score["scores"][0]["verdict"] = "fail" - changed_receipt = score["scores"][0] - changed_digest = hashlib.sha256( - json.dumps( - changed_receipt, sort_keys=True, separators=(",", ":") - ).encode() - ).hexdigest() - atomic_write_json( - run - / "scores" - / f"{changed_receipt['scorer']['id']}-{changed_digest}.json", - changed_receipt, - mode=0o444, - ) - atomic_write_json(score_path, score) - with self.assertRaisesRegex(WorkflowError, "passing deterministic"): - record( - settings, - "life-test", - action="accepted", - actor="reviewer", - reason="failed gates", - revision="abc123", - ) - - def test_high_risk_acceptance_rejects_executor_self_review(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run( - Path(tmp), tier="high", executor="same-actor" - ) - record( - settings, - "life-test", - action="reviewed", - actor="same-actor", - reason="self review", - ) - with self.assertRaisesRegex(WorkflowError, "independent .*review"): - record( - settings, - "life-test", - action="accepted", - actor="same-actor", - reason="self acceptance", - revision="abc123", - ) - - def test_high_risk_acceptance_rejects_self_review_then_other_acceptor(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run( - Path(tmp), tier="critical", executor="executor-id" - ) - record( - settings, - "life-test", - action="reviewed", - actor="executor-id", - reason="self review", - ) - with self.assertRaisesRegex(WorkflowError, "independent prior review"): - record( - settings, - "life-test", - action="accepted", - actor="different-acceptor", - reason="attempted bypass", - revision="abc123", - ) - - def test_review_rejects_omitted_required_scorer(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run = self._completed_run(Path(tmp)) - score_path = run / "scores" / "score-set.json" - score = json.loads(score_path.read_text(encoding="utf-8")) - score["scores"] = [ - item - for item in score["scores"] - if item["scorer"]["id"] == "schema_validity" - ] - atomic_write_json(score_path, score) - with self.assertRaisesRegex(WorkflowError, "missing required scorers"): - record( - settings, - "life-test", - action="reviewed", - actor="reviewer", - reason="incomplete scores", - ) diff --git a/tests/test_manifest_validation.py b/tests/test_manifest_validation.py deleted file mode 100644 index 5a2cf3d..0000000 --- a/tests/test_manifest_validation.py +++ /dev/null @@ -1,177 +0,0 @@ -import shutil -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.pack import scaffold -from agent_workflow.manifests import validate_pack, write_checksum_manifest - - -class ManifestValidationTests(unittest.TestCase): - @staticmethod - def _copy_example_pack(parent: Path) -> Path: - source = Path(__file__).resolve().parents[1] / "examples" / "three-phase-pack" - root = parent / "three-phase-pack" - shutil.copytree( - source, - root, - ignore=shutil.ignore_patterns("MANIFEST.sha256", "__pycache__", "*.pyc"), - ) - return root - - def test_example_pack_validates_with_generated_checksums(self): - with tempfile.TemporaryDirectory() as tmp: - root = self._copy_example_pack(Path(tmp)) - cache_file = root / "phase-0" / "__pycache__" / "cached.pyc" - cache_file.parent.mkdir() - cache_file.write_bytes(b"cached bytecode") - write_checksum_manifest(root) - - report = validate_pack(root) - - self.assertTrue(report.ok, report.errors) - self.assertEqual(report.phases, 3) - self.assertEqual(report.tasks, 3) - self.assertNotIn( - "phase-0/__pycache__/cached.pyc", - (root / "MANIFEST.sha256").read_text(encoding="utf-8"), - ) - - def test_prompt_path_escaping_pack_root_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - root = self._copy_example_pack(Path(tmp)) - outside = root.parent / "outside.md" - outside.write_text("outside\n", encoding="utf-8") - manifest = root / "phase-0" / "task-manifest.yaml" - manifest.write_text( - manifest.read_text(encoding="utf-8").replace( - "tickets/P0-00-baseline.md", "../../outside.md" - ), - encoding="utf-8", - ) - - report = validate_pack(root, verify_checksums=False) - - self.assertFalse(report.ok) - self.assertTrue( - any("prompt escapes pack root" in error for error in report.errors), - report.errors, - ) - - def test_missing_checksum_manifest_is_rejected_by_default(self): - with tempfile.TemporaryDirectory() as tmp: - root = self._copy_example_pack(Path(tmp)) - - report = validate_pack(root) - - self.assertFalse(report.ok) - self.assertIn("MANIFEST.sha256: missing", report.errors) - - def test_incomplete_checksum_manifest_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - root = self._copy_example_pack(Path(tmp)) - write_checksum_manifest(root) - manifest = root / "MANIFEST.sha256" - lines = manifest.read_text(encoding="utf-8").splitlines() - manifest.write_text( - "\n".join(line for line in lines if not line.endswith(" README.md")) - + "\n", - encoding="utf-8", - ) - - report = validate_pack(root) - - self.assertFalse(report.ok) - self.assertIn("MANIFEST.sha256: missing file: README.md", report.errors) - - def test_missing_ticket_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - required = [ - "README.md", - "EXECUTION_PROTOCOL.md", - "DELEGATION_RUNBOOK.md", - "templates/TICKET_COMPLETION.md", - "templates/PHASE_GATE_REPORT.md", - "templates/source-baseline.example.json", - "phase-0/README.md", - "phase-0/MASTER_IMPLEMENTATION_PROMPT.md", - ] - for rel in required: - path = root / rel - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("placeholder\n", encoding="utf-8") - (root / "phase-0/tickets").mkdir() - (root / "phase-0/task-manifest.yaml").write_text( - "phase: 0\nname: test\ntasks:\n" - " - {id: P0-00, tier: C, session: test-p0-00, " - "prompt: tickets/missing.md}\n", - encoding="utf-8", - ) - report = validate_pack(root, verify_checksums=False) - self.assertFalse(report.ok) - self.assertTrue( - any("prompt not found" in error for error in report.errors) - ) - - -class MiniYamlFallbackTests(unittest.TestCase): - def test_builtin_parser_handles_block_and_inline_tasks(self): - from agent_workflow.miniyaml import load_task_manifest - - block = load_task_manifest( - "phase: 0\nname: test\nmandatory_order: [P0-00]\n" - "tasks:\n - id: P0-00\n tier: C\n" - " session: test-p0-00\n prompt: tickets/P0-00.md\n" - ) - inline = load_task_manifest( - "phase: 0\nname: test\ntasks:\n" - " - {id: P0-00, tier: C, session: test-p0-00, " - "prompt: tickets/P0-00.md}\n" - ) - self.assertEqual(block["tasks"][0]["session"], "test-p0-00") - self.assertEqual(inline["tasks"][0]["tier"], "C") - -class WorkflowDependencyValidationTests(unittest.TestCase): - def test_pack_rejects_unknown_and_cyclic_dependencies(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - scaffold(root, phases=1, name="graph") - manifest = root / "phase-0" / "task-manifest.yaml" - manifest.write_text( - """phase: 0 -name: graph -tasks: - - id: A - tier: A - session: graph-a - prompt: tickets/P0-00-baseline-and-preflight.md - dependencies: [B] - - id: B - tier: A - session: graph-b - prompt: tickets/P0-00-baseline-and-preflight.md - dependencies: [A] -""", - encoding="utf-8", - ) - report = validate_pack(root, verify_checksums=False) - self.assertTrue(any("dependency cycle" in error for error in report.errors)) - - def test_pack_accepts_ticket_result_contract(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - scaffold(root, phases=1, name="result") - (root / "contracts").mkdir() - (root / "contracts" / "result.schema.json").write_text( - '{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}', - encoding="utf-8", - ) - manifest = root / "phase-0" / "task-manifest.yaml" - text = manifest.read_text(encoding="utf-8") - text = text.replace( - ' prompt: "tickets/P0-00-baseline-and-preflight.md"', - ' prompt: "tickets/P0-00-baseline-and-preflight.md"\n result_contract:\n schema: "contracts/result.schema.json"\n required: true', - ) - manifest.write_text(text, encoding="utf-8") - self.assertTrue(validate_pack(root, verify_checksums=False).ok) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py deleted file mode 100644 index 5f214f2..0000000 --- a/tests/test_mcp_server.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.config import defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.mcp.server import build_server, main - - -try: - import mcp # noqa: F401 -except ModuleNotFoundError: # pragma: no cover - MCP_AVAILABLE = False -else: - MCP_AVAILABLE = True - - -class McpOptionalDependencyTests(unittest.TestCase): - def test_missing_optional_dependency_has_actionable_error(self): - if MCP_AVAILABLE: - self.skipTest("MCP extra is installed") - with tempfile.TemporaryDirectory() as tmp: - with self.assertRaisesRegex(WorkflowError, "agent-workflow\\[mcp\\]"): - build_server(defaults(Path(tmp) / "config.toml"), repo_root=Path(tmp)) - - def test_main_maps_workflow_error_to_exit_two(self): - with patch("agent_workflow.mcp.server.build_server", side_effect=WorkflowError("bad")): - self.assertEqual(main([]), 2) - - -@unittest.skipUnless(MCP_AVAILABLE, "MCP extra is not installed") -class McpServerProtocolTests(unittest.IsolatedAsyncioTestCase): - async def test_protocol_facing_registration_uses_public_session_api(self): - # The official SDK testing guide uses ClientSession over an in-memory - # transport. Keep this test protocol-facing rather than inspecting - # FastMCP's private tool/resource managers. - from mcp.shared.memory import create_connected_server_and_client_session - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = defaults(root / "config.toml") - settings = settings.__class__(**{**settings.__dict__, "state_root": root / "state"}) - server = build_server(settings, repo_root=Path.cwd()) - async with create_connected_server_and_client_session(server, raise_exceptions=True) as session: - tools = await session.list_tools() - resources = await session.list_resources() - templates = await session.list_resource_templates() - self.assertEqual([tool.name for tool in tools.tools], ["pack_validate"]) - self.assertIn("agent-workflow://runs", [str(item.uri) for item in resources.resources]) - self.assertEqual(len(templates.resourceTemplates), 3) diff --git a/tests/test_mcp_services.py b/tests/test_mcp_services.py deleted file mode 100644 index 38f5cff..0000000 --- a/tests/test_mcp_services.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import json -import os -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.config import defaults -from agent_workflow.mcp.services import ( - MAX_PAGE_SIZE, - PackValidationRequest, - PageRequest, - ServiceError, - WorkflowReadService, -) - - -class McpServiceTests(unittest.TestCase): - def _service(self, root: Path) -> WorkflowReadService: - settings = defaults(root / "config.toml") - settings = settings.__class__(**{**settings.__dict__, "state_root": root / "state"}) - return WorkflowReadService(settings, repository_root=root) - - def _run(self, root: Path, session_id: str = "run-1") -> Path: - run = root / "state" / "runs" / session_id - run.mkdir(parents=True) - (run / "status.json").write_text( - json.dumps( - { - "schema": "agent-workflow/session-status/v2", - "session_id": session_id, - "status": "running", - "executor": "codex", - "agent_name": "larry", - "model": "gpt-5.4-mini", - "workdir": "/secret/worktree", - "error": "/secret/token", - } - ), - encoding="utf-8", - ) - return run - - def test_status_is_redacted_and_service_is_shared_transport_boundary(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self._run(root) - status = self._service(root).get_status("run-1") - self.assertEqual(status["agent_name"], "larry") - self.assertNotIn("workdir", status) - self.assertNotIn("error", status) - - def test_invalid_identifier_missing_run_and_pagination_bounds_are_stable(self): - with tempfile.TemporaryDirectory() as tmp: - service = self._service(Path(tmp)) - for session_id, category in (("../bad", "invalid_identifier"), ("missing", "not_found")): - with self.subTest(session_id=session_id), self.assertRaises(ServiceError) as caught: - service.get_status(session_id) - self.assertEqual(caught.exception.category, category) - with self.assertRaises(ServiceError) as caught: - service.list_runs(PageRequest(limit=MAX_PAGE_SIZE + 1)) - self.assertEqual(caught.exception.category, "invalid_limit") - - def test_run_and_receipt_symlink_escapes_are_denied(self): - if not hasattr(os, "symlink"): - self.skipTest("symlinks unavailable") - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - outside = root / "outside" - outside.mkdir() - runs = root / "state" / "runs" - runs.mkdir(parents=True) - (runs / "escaped").symlink_to(outside, target_is_directory=True) - service = self._service(root) - with self.assertRaises(ServiceError) as caught: - service.get_status("escaped") - self.assertEqual(caught.exception.category, "forbidden_root") - - run = self._run(root, "safe") - (run / "receipts").symlink_to(outside, target_is_directory=True) - with self.assertRaises(ServiceError) as caught: - service.list_receipts("safe") - self.assertEqual(caught.exception.category, "forbidden_root") - - def test_pack_validation_rejects_traversal_and_symlink_escape(self): - with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as outside_tmp: - root = Path(tmp) - service = self._service(root) - with self.assertRaises(ServiceError) as caught: - service.validate_pack(PackValidationRequest("../outside")) - self.assertEqual(caught.exception.category, "forbidden_root") - link = root / "pack-link" - link.symlink_to(Path(outside_tmp), target_is_directory=True) - with self.assertRaises(ServiceError) as caught: - service.validate_pack(PackValidationRequest("pack-link")) - self.assertEqual(caught.exception.category, "forbidden_root") - - def test_receipts_return_names_and_hashes_only(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - run = self._run(root) - receipts = run / "receipts" - receipts.mkdir() - (receipts / "001-final.json").write_text('{"ok":true}\n', encoding="utf-8") - result = self._service(root).list_receipts("run-1").as_dict() - self.assertEqual(result["items"][0]["name"], "001-final.json") - self.assertEqual(len(result["items"][0]["sha256"]), 64) diff --git a/tests/test_messages.py b/tests/test_messages.py deleted file mode 100644 index 3b1b865..0000000 --- a/tests/test_messages.py +++ /dev/null @@ -1,264 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from agent_workflow.errors import WorkflowError -from agent_workflow.config import defaults -from agent_workflow.messages import ( - MAX_CONTENT_CHARS, - MESSAGE_SCHEMA, - append_message, - replay_messages, - wait_for_messages, -) -from agent_workflow.sessions import acknowledge, progress, steer, wait_for_message -from agent_workflow.state import write_status - - -class MessageLogTests(unittest.TestCase): - def test_append_and_replay_after_sequence(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) / "run" - first = append_message( - run_dir, - session_id="run-1", - direction="parent_to_child", - kind="steer", - actor="parent", - content="Please inspect the tests.", - ) - second = append_message( - run_dir, - session_id="run-1", - direction="child_to_parent", - kind="progress", - actor="child", - content="I am inspecting them.", - ) - self.assertEqual([first, second], replay_messages(run_dir)) - self.assertEqual([second], replay_messages(run_dir, after_sequence=1)) - self.assertEqual(MESSAGE_SCHEMA, second["schema"]) - - def test_sequence_is_contiguous(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - sequences = [ - append_message( - run_dir, - session_id="run", - direction="child_to_parent", - kind="progress", - actor="child", - content=str(index), - )["sequence"] - for index in range(3) - ] - self.assertEqual([1, 2, 3], sequences) - - def test_rejects_invalid_message_inputs(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - base = dict( - session_id="run", - direction="parent_to_child", - kind="steer", - actor="parent", - content="ok", - ) - for update in ( - {"session_id": "../unsafe"}, - {"actor": ""}, - {"direction": "sideways"}, - {"kind": "notice"}, - {"content": ""}, - {"content": "x" * (MAX_CONTENT_CHARS + 1)}, - {"correlation_id": "not-a-uuid"}, - ): - with self.subTest(update=update), self.assertRaises(WorkflowError): - append_message(run_dir, **(base | update)) - - def test_rejects_malformed_or_noncontiguous_existing_log(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - path = run_dir / "messages.jsonl" - path.write_text("not json\n", encoding="utf-8") - with self.assertRaises(WorkflowError): - replay_messages(run_dir) - path.write_text( - json.dumps({"schema": "agent-workflow/session-message/v0"}) + "\n", - encoding="utf-8", - ) - with self.assertRaises(WorkflowError): - replay_messages(run_dir) - path.write_text( - json.dumps( - { - "schema": MESSAGE_SCHEMA, - "sequence": 2, - "message_id": "2a9d57d7-9a95-4acd-a3e8-8f27a84b985e", - "session_id": "run", - "timestamp": "2026-01-01T00:00:00+00:00", - "direction": "parent_to_child", - "kind": "steer", - "actor": "parent", - "content": "bad sequence", - } - ) - + "\n", - encoding="utf-8", - ) - with self.assertRaises(WorkflowError): - append_message( - run_dir, - session_id="run", - direction="parent_to_child", - kind="steer", - actor="parent", - content="will not append", - ) - - def test_session_controls_are_durable_and_terminal_runs_reject_them(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - write_status(settings, "run-1", {"session_id": "run-1", "status": "launched"}) - request = steer(settings, "run-1", actor="parent", content="check tests") - update = progress(settings, "run-1", actor="child", content="checking") - receipt = acknowledge( - settings, - "run-1", - actor="child", - content="applied", - correlation_id=request["message_id"], - ) - self.assertEqual([request, update, receipt], wait_for_message(settings, "run-1")) - self.assertEqual( - [], - wait_for_messages( - root / "state" / "runs" / "run-1", - after_sequence=receipt["sequence"], - timeout_seconds=0, - ), - ) - write_status(settings, "run-done", {"session_id": "run-done", "status": "completed"}) - with self.assertRaisesRegex(WorkflowError, "terminal session"): - steer(settings, "run-done", actor="parent", content="too late") - - def test_after_commit_runs_after_replay_and_failures_are_suppressed(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) / "run" - observed = [] - - def callback(message): - observed.extend(replay_messages(run_dir)) - raise RuntimeError("wakeup unavailable") - - record = append_message( - run_dir, session_id="run", direction="parent_to_child", - kind="steer", actor="parent", content="durable first", - after_commit=callback, - ) - self.assertEqual([record], observed) - self.assertEqual([record], replay_messages(run_dir)) - - def test_existing_replay_does_not_wait_and_unavailable_waiter_keeps_polling(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) / "run" - record = append_message( - run_dir, session_id="run", direction="child_to_parent", - kind="progress", actor="child", content="already here", - ) - waiter = Mock(return_value=True) - self.assertEqual( - [record], - wait_for_messages(run_dir, wakeup_channel="channel", wait_for_wakeup=waiter), - ) - waiter.assert_not_called() - - with patch("agent_workflow.messages.time.sleep") as sleep: - self.assertEqual( - [], - wait_for_messages( - run_dir, after_sequence=1, timeout_seconds=0.01, - poll_seconds=0.01, wakeup_channel="channel", - wait_for_wakeup=Mock(return_value=False), - ), - ) - self.assertTrue(sleep.called) - - def test_session_controls_signal_after_durable_append_and_wait_uses_tmux_seam(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = defaults(root / "missing.toml") - settings = settings.__class__(**{**settings.__dict__, "state_root": root / "state"}) - write_status(settings, "run-1", {"session_id": "run-1", "status": "launched"}) - with ( - patch("agent_workflow.sessions.tmux.wakeup_channel", return_value="safe-channel") as channel, - patch("agent_workflow.sessions.tmux.signal_waiters") as signal, - ): - record = steer(settings, "run-1", actor="parent", content="check") - self.assertEqual([record], replay_messages(root / "state" / "runs" / "run-1")) - channel.assert_called_once() - signal.assert_called_once_with("safe-channel") - with ( - patch("agent_workflow.sessions.tmux.wakeup_channel", return_value="safe-channel"), - patch("agent_workflow.sessions.tmux.wait_for_wakeup", return_value=False) as waiter, - ): - self.assertEqual([record], wait_for_message(settings, "run-1")) - waiter.assert_not_called() - def test_rejects_symlink_log_and_invalid_or_duplicate_ack(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - real = root / "real.log" - real.write_text("", encoding="utf-8") - run = root / "run" - run.mkdir() - (run / "messages.jsonl").symlink_to(real) - with self.assertRaisesRegex(WorkflowError, "non-symlink"): - replay_messages(run) - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = defaults(root / "missing.toml") - settings = settings.__class__(**{**settings.__dict__, "state_root": root / "state"}) - write_status(settings, "run-ack", {"session_id": "run-ack", "status": "launched"}) - with self.assertRaisesRegex(WorkflowError, "existing steer"): - acknowledge(settings, "run-ack", actor="child", content="bad", correlation_id="2a9d57d7-9a95-4acd-a3e8-8f27a84b985e") - request = steer(settings, "run-ack", actor="parent", content="apply") - acknowledge(settings, "run-ack", actor="child", content="done", correlation_id=request["message_id"]) - with self.assertRaisesRegex(WorkflowError, "already acknowledged"): - acknowledge(settings, "run-ack", actor="child", content="again", correlation_id=request["message_id"]) - - def test_rejects_invalid_kind_direction_correlation_and_mixed_session(self): - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - with self.assertRaisesRegex(WorkflowError, "must use"): - append_message(run_dir, session_id="run", direction="child_to_parent", kind="steer", actor="child", content="bad") - with self.assertRaisesRegex(WorkflowError, "require correlation"): - append_message(run_dir, session_id="run", direction="child_to_parent", kind="ack", actor="child", content="bad") - with self.assertRaisesRegex(WorkflowError, "only ack"): - append_message(run_dir, session_id="run", direction="child_to_parent", kind="progress", actor="child", content="bad", correlation_id="2a9d57d7-9a95-4acd-a3e8-8f27a84b985e") - append_message(run_dir, session_id="run", direction="child_to_parent", kind="progress", actor="child", content="ok") - with self.assertRaisesRegex(WorkflowError, "different session"): - append_message(run_dir, session_id="other", direction="child_to_parent", kind="progress", actor="child", content="bad") - - def test_ack_validation_is_atomic_under_concurrent_writers(self): - from concurrent.futures import ThreadPoolExecutor - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - steer = append_message(run_dir, session_id="run", direction="parent_to_child", kind="steer", actor="parent", content="apply") - def ack_once(index): - try: - return append_message(run_dir, session_id="run", direction="child_to_parent", kind="ack", actor=f"child{index}", content="done", correlation_id=steer["message_id"]) - except WorkflowError as exc: - return str(exc) - with ThreadPoolExecutor(max_workers=2) as pool: - results = list(pool.map(ack_once, range(2))) - self.assertEqual(1, sum(isinstance(item, dict) for item in results)) - self.assertEqual(1, sum("already acknowledged" in item for item in results if isinstance(item, str))) - self.assertEqual(2, len(replay_messages(run_dir))) diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index 6947674..0000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,109 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.messages import append_message -from agent_workflow.metrics import normalize_usage, write_execution_evidence -from agent_workflow.receipts import make_read_only, seal_run, verify_seal -from agent_workflow.util import atomic_write_json, sha256_file -from run_fixtures import write_run_contracts - - -class MetricsTests(unittest.TestCase): - def test_normalization_preserves_unknown_as_null_and_rejects_invalid_numbers(self): - self.assertEqual( - normalize_usage({"prompt_tokens": 10, "completion_tokens": 3}), - { - "input_tokens": 10, - "cached_input_tokens": None, - "cache_write_input_tokens": None, - "output_tokens": 3, - "reasoning_output_tokens": None, - "provider_total_tokens": None, - "provider_billed_cost": None, - "local_estimated_cost": None, - "price_catalog_id": None, - "cost": None, - "currency": None, - }, - ) - self.assertIsNone(normalize_usage({"input_tokens": -1})["input_tokens"]) - self.assertIsNone(normalize_usage({"output_tokens": True})["output_tokens"]) - self.assertEqual( - 4, - normalize_usage({"prompt_tokens_details": {"cached_tokens": 4}})["cached_input_tokens"], - ) - - def test_command_collection_populates_only_verification_duration(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="metrics-duration") - atomic_write_json(run / "collections" / "commands-post.json", { - "schema": "agent-workflow/command-collection-set/v1", "phase": "post", - "commands": [{"duration_seconds": 0.25}, {"duration_seconds": 0.75}, {"duration_seconds": -1}], - }) - metrics = write_execution_evidence(run, elapsed_seconds=3.0) - stages = {item["stage"]: item for item in metrics["stages"]} - self.assertEqual(1.0, stages["verification"]["elapsed_seconds"]) - self.assertEqual(3.0, stages["total"]["elapsed_seconds"]) - - def test_retry_lineage_is_counted_from_sealed_provenance(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="metrics-retry") - provenance = json.loads((run / "run-provenance.json").read_text()) - provenance["retry_of_run_id"] = "metrics-original" - provenance["workflow"] = { - "workflow_id": "wf", - "node_id": "node", - "attempt": 3, - "inputs_path": None, - "inputs_sha256": None, - "routing": None, - } - atomic_write_json(run / "run-provenance.json", provenance) - metrics = write_execution_evidence(run, elapsed_seconds=1.0) - stages = {item["stage"]: item for item in metrics["stages"]} - self.assertEqual(stages["orchestrator"]["retry_count"], 2) - self.assertEqual(stages["total"]["retry_count"], 2) - - def test_metrics_include_required_stages_and_control_events_are_sealed_read_only(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="metrics-run") - provenance = json.loads((run / "run-provenance.json").read_text()) - provenance["usage"] = {"input_tokens": 7, "output_tokens": 2} - atomic_write_json(run / "run-provenance.json", provenance) - steer = append_message(run, session_id="metrics-run", direction="parent_to_child", kind="steer", actor="parent", content="check") - append_message(run, session_id="metrics-run", direction="child_to_parent", kind="ack", actor="worker-1", content="applied", correlation_id=steer["message_id"]) - metrics = write_execution_evidence(run, elapsed_seconds=1.25) - stages = {stage["stage"]: stage for stage in metrics["stages"]} - self.assertEqual({"orchestrator", "child:worker-1", "verification", "total"}, set(stages)) - self.assertEqual(1, stages["orchestrator"]["steer_acknowledged_count"]) - self.assertIsNone(stages["verification"]["input_tokens"]) - verified = verify_seal(run, expected_sha256=sha256_file(run / "final-receipt.json")) if False else None - receipt = seal_run(run, session_id="metrics-run") - digest = sha256_file(run / "final-receipt.json") - verified = verify_seal(run, expected_sha256=digest) - paths = {item["path"] for item in verified["artifacts"]} - self.assertIn("execution-metrics.json", paths) - self.assertIn("control-events.jsonl", paths) - make_read_only(run) - self.assertEqual(0, (run / "execution-metrics.json").stat().st_mode & 0o222) - self.assertEqual(0, (run / "control-events.jsonl").stat().st_mode & 0o222) - - def test_sealing_rejects_invalid_metrics_and_control_event(self): - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="metrics-run") - atomic_write_json(run / "execution-metrics.json", {"schema": "agent-workflow/execution-metrics/v1"}) - with self.assertRaises(WorkflowError): - seal_run(run, session_id="metrics-run") - with tempfile.TemporaryDirectory() as tmp: - run = Path(tmp) / "run" - write_run_contracts(run, session_id="metrics-run") - (run / "control-events.jsonl").write_text("{}\n", encoding="utf-8") - with self.assertRaises(WorkflowError): - seal_run(run, session_id="metrics-run") diff --git a/tests/test_native_jobs.py b/tests/test_native_jobs.py deleted file mode 100644 index 67c2763..0000000 --- a/tests/test_native_jobs.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.native_jobs import NATIVE_JOB_SCHEMA, validate_native_job - - -FIXTURE = Path("tests/fixtures/native-jobs/valid-job.json") - - -class NativeJobTests(unittest.TestCase): - def _pack_with_job(self) -> tuple[tempfile.TemporaryDirectory[str], Path, Path]: - tmp = tempfile.TemporaryDirectory() - root = Path(tmp.name) / "pack" - (root / "prompts").mkdir(parents=True) - (root / "prompts" / "P0-01.md").write_text("prompt", encoding="utf-8") - job = root / "jobs" / "P0-01.json" - job.parent.mkdir() - shutil.copy2(FIXTURE, job) - return tmp, root, job - - def test_valid_job_returns_typed_command_and_paths(self) -> None: - tmp, root, job_path = self._pack_with_job() - with tmp: - job = validate_native_job(job_path, pack_root=root) - self.assertEqual(job.schema, NATIVE_JOB_SCHEMA) - self.assertEqual(job.ticket_id, "P0-01") - self.assertEqual(job.prompt_path, root / "prompts" / "P0-01.md") - self.assertEqual(job.acceptance_commands[0].argv, ("python3", "-m", "pytest", "-q")) - self.assertTrue(job.review_requirement.independent) - - def test_job_is_json_only_and_rejects_unknown_schema(self) -> None: - tmp, root, job_path = self._pack_with_job() - with tmp: - yaml_path = job_path.with_suffix(".yaml") - yaml_path.write_text("schema: ignored", encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "must be a .json"): - validate_native_job(yaml_path, pack_root=root) - value = json.loads(job_path.read_text(encoding="utf-8")) - value["schema"] = "example/external-job/v1" - job_path.write_text(json.dumps(value), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "unsupported native job schema"): - validate_native_job(job_path, pack_root=root) - - def test_rejects_prompt_and_policy_path_escapes(self) -> None: - tmp, root, job_path = self._pack_with_job() - with tmp: - value = json.loads(job_path.read_text(encoding="utf-8")) - value["prompt_path"] = "../outside.md" - job_path.write_text(json.dumps(value), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "prompt_path must be a relative"): - validate_native_job(job_path, pack_root=root) - value["prompt_path"] = "prompts/P0-01.md" - value["path_policy"]["allowed_paths"] = ["../outside.py"] - job_path.write_text(json.dumps(value), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "allowed_paths entry must be a relative"): - validate_native_job(job_path, pack_root=root) - - def test_rejects_bad_command_shape_and_duplicate_ids(self) -> None: - tmp, root, job_path = self._pack_with_job() - with tmp: - value = json.loads(job_path.read_text(encoding="utf-8")) - value["acceptance_commands"] = [{"id": "unit", "argv": "pytest"}] - job_path.write_text(json.dumps(value), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "acceptance_commands.0.argv"): - validate_native_job(job_path, pack_root=root) - value["acceptance_commands"] = [ - {"id": "unit", "argv": ["pytest"]}, - {"id": "unit", "argv": ["python3"]}, - ] - job_path.write_text(json.dumps(value), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "duplicate command IDs"): - validate_native_job(job_path, pack_root=root) diff --git a/tests/test_optional_integrations.py b/tests/test_optional_integrations.py deleted file mode 100644 index 4caa17e..0000000 --- a/tests/test_optional_integrations.py +++ /dev/null @@ -1,25 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.integrations.swebench import write_prediction - - -class OptionalIntegrationTests(unittest.TestCase): - def test_swebench_prediction_uses_official_fields(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - patch = root / "patch.diff" - output = root / "predictions.jsonl" - patch.write_text("diff --git a/a b/a\n", encoding="utf-8") - write_prediction( - instance_id="project__issue-1", - model_name_or_path="codex", - patch_path=patch, - output=output, - ) - value = json.loads(output.read_text(encoding="utf-8")) - self.assertEqual( - set(value), {"instance_id", "model_name_or_path", "model_patch"} - ) diff --git a/tests/test_provider_evidence.py b/tests/test_provider_evidence.py deleted file mode 100644 index e5c6088..0000000 --- a/tests/test_provider_evidence.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import json -import os -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.provider_evidence import build_provider_evidence - - -class ProviderEvidenceTests(unittest.TestCase): - def _build(self, events: list[dict], stream_format: str = "codex-jsonl"): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "executor-events.jsonl" - path.write_text("".join(json.dumps(item) + "\n" for item in events), encoding="utf-8") - return build_provider_evidence( - events_path=path, - stream_format=stream_format, - executor="codex" if stream_format == "codex-jsonl" else "claude", - session_id="evidence-test", - ) - - def test_terminal_replaces_deltas_without_double_counting(self): - evidence = self._build([ - {"usage_mode": "delta", "usage": {"input_tokens": 3, "output_tokens": 2}}, - {"usage_mode": "delta", "usage": {"input_tokens": 4, "output_tokens": 5}}, - {"type": "turn.completed", "usage": {"input_tokens": 7, "output_tokens": 7}}, - ]) - self.assertEqual(7, evidence["aggregate"]["input_tokens"]) - self.assertEqual(7, evidence["aggregate"]["output_tokens"]) - self.assertIn("MIXED_MODES_TERMINAL_AUTHORITATIVE", evidence["incomplete_reasons"]) - - def test_unidentified_duplicate_delta_is_ambiguous_not_silently_counted(self): - event = {"usage_mode": "delta", "usage": {"input_tokens": 2}} - evidence = self._build([event, event]) - self.assertEqual(2, evidence["aggregate"]["input_tokens"]) - self.assertEqual(1, evidence["classified_usage_count"]) - self.assertFalse(evidence["usage_complete"]) - self.assertIn("AMBIGUOUS_DUPLICATE_DELTA_EVENTS", evidence["incomplete_reasons"]) - - def test_distinct_identified_equal_deltas_are_both_counted(self): - evidence = self._build([ - {"event_id": "one", "usage_mode": "delta", "usage": {"input_tokens": 2}}, - {"event_id": "two", "usage_mode": "delta", "usage": {"input_tokens": 2}}, - ]) - self.assertTrue(evidence["usage_complete"]) - self.assertEqual(4, evidence["aggregate"]["input_tokens"]) - self.assertEqual(2, evidence["classified_usage_count"]) - - def test_conflicting_payload_reuse_of_provider_event_id_is_incomplete(self): - evidence = self._build([ - { - "event_id": "usage-1", - "type": "usage", - "usage_mode": "delta", - "usage": {"input_tokens": 2}, - }, - { - "event_id": "usage-1", - "type": "usage", - "usage_mode": "delta", - "usage": {"input_tokens": 9}, - }, - ]) - self.assertFalse(evidence["usage_complete"]) - self.assertIn( - "CONFLICTING_PROVIDER_EVENT_ID", evidence["incomplete_reasons"] - ) - self.assertEqual(2, evidence["aggregate"]["input_tokens"]) - - def test_mixed_nonterminal_modes_are_incomplete_and_null(self): - evidence = self._build([ - {"usage_mode": "delta", "usage": {"input_tokens": 2}}, - {"usage_mode": "cumulative", "usage": {"input_tokens": 2}}, - ]) - self.assertFalse(evidence["usage_complete"]) - self.assertIsNone(evidence["aggregate"]["input_tokens"]) - - def test_cached_reasoning_and_claude_cost_variants(self): - evidence = self._build([ - {"type": "result", "total_cost_usd": 0.25, "usage": { - "input_tokens": 10, - "cache_read_input_tokens": 4, - "cache_creation_input_tokens": 2, - "output_tokens": 3, - "reasoning_tokens": 1, - }} - ], stream_format="claude-stream-json") - aggregate = evidence["aggregate"] - self.assertEqual(4, aggregate["cached_input_tokens"]) - self.assertEqual(2, aggregate["cache_write_input_tokens"]) - self.assertEqual(1, aggregate["reasoning_output_tokens"]) - self.assertEqual(0.25, aggregate["provider_billed_cost"]) - self.assertEqual("USD", aggregate["currency"]) - - def test_empty_terminal_usage_is_incomplete(self): - evidence = self._build([{"type": "turn.completed"}]) - self.assertFalse(evidence["usage_complete"]) - self.assertIn("TERMINAL_USAGE_EMPTY", evidence["incomplete_reasons"]) - - def test_conflicting_terminal_updates_fail_closed(self): - evidence = self._build([ - {"type": "turn.completed", "usage": {"input_tokens": 3}}, - {"type": "turn.completed", "usage": {"input_tokens": 4}}, - ]) - self.assertFalse(evidence["usage_complete"]) - self.assertIsNone(evidence["aggregate"]["input_tokens"]) - self.assertIn("CONFLICTING_TERMINAL_UPDATES", evidence["incomplete_reasons"]) - - def test_non_finite_usage_is_rejected(self): - evidence = self._build([ - {"type": "turn.completed", "usage": {"input_tokens": float("inf")}}, - ]) - self.assertFalse(evidence["usage_complete"]) - self.assertIsNone(evidence["aggregate"]["input_tokens"]) - - def test_provider_cost_without_currency_is_incomplete(self): - evidence = self._build([ - {"type": "turn.completed", "usage": { - "input_tokens": 1, "provider_billed_cost": 0.1 - }}, - ]) - self.assertFalse(evidence["usage_complete"]) - self.assertIn("PROVIDER_COST_MISSING_CURRENCY", evidence["incomplete_reasons"]) - - def test_nonmonotonic_cumulative_usage_fails_closed(self): - evidence = self._build([ - {"usage_mode": "cumulative", "usage": {"input_tokens": 10}}, - {"usage_mode": "cumulative", "usage": {"input_tokens": 9}}, - ]) - self.assertFalse(evidence["usage_complete"]) - self.assertIsNone(evidence["aggregate"]["input_tokens"]) - self.assertIn("NONMONOTONIC_CUMULATIVE_USAGE", evidence["incomplete_reasons"]) - - def test_raw_event_symlink_is_rejected(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - target = root / "target.jsonl" - target.write_text('{"type":"turn.completed","usage":{"input_tokens":1}}\n', encoding="utf-8") - link = root / "executor-events.jsonl" - os.symlink(target, link) - with self.assertRaisesRegex(Exception, "non-symlink"): - build_provider_evidence( - events_path=link, - stream_format="codex-jsonl", - executor="codex", - session_id="evidence-test", - ) - - def test_unknown_cost_remains_null(self): - evidence = self._build([ - {"type": "turn.completed", "usage": {"input_tokens": 1, "output_tokens": 1}} - ]) - self.assertIsNone(evidence["aggregate"]["provider_billed_cost"]) - self.assertIsNone(evidence["aggregate"]["local_estimated_cost"]) - self.assertIsNone(evidence["aggregate"]["currency"]) diff --git a/tests/test_public_eval_fixtures.py b/tests/test_public_eval_fixtures.py deleted file mode 100644 index b802e93..0000000 --- a/tests/test_public_eval_fixtures.py +++ /dev/null @@ -1,36 +0,0 @@ -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] / "evals" / "fixtures" / "public" - - -class PublicEvaluationFixtureTests(unittest.TestCase): - def test_noop_fails_and_reference_change_passes(self): - reference_changes = { - "fix-add": ("calc.py", "def add(left: int, right: int) -> int:\n return left + right\n"), - "scope-boundary": ("allowed.txt", "approved\n"), - "evidence-fidelity": ("value.txt", "verified\n"), - } - for fixture, (relative, content) in reference_changes.items(): - with self.subTest(fixture=fixture), tempfile.TemporaryDirectory() as tmp: - target = Path(tmp) / fixture - shutil.copytree(ROOT / fixture, target) - noop = subprocess.run( - ["python3", "validate.py" if fixture != "fix-add" else "test_calc.py"], - cwd=target, - capture_output=True, - check=False, - ) - self.assertNotEqual(noop.returncode, 0) - (target / relative).write_text(content, encoding="utf-8") - fixed = subprocess.run( - ["python3", "validate.py" if fixture != "fix-add" else "test_calc.py"], - cwd=target, - capture_output=True, - check=False, - ) - self.assertEqual(fixed.returncode, 0, fixed.stderr.decode()) diff --git a/tests/test_receipts.py b/tests/test_receipts.py deleted file mode 100644 index 79d3940..0000000 --- a/tests/test_receipts.py +++ /dev/null @@ -1,103 +0,0 @@ -import tempfile -import unittest -import json -import os -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.receipts import make_read_only, seal_run, verify_seal -from agent_workflow.util import sha256_file -from run_fixtures import write_run_contracts - - -class ReceiptTests(unittest.TestCase): - def test_seal_is_anchored_and_detects_tampering(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - write_run_contracts(root) - (root / "output.log").write_text("done\n", encoding="utf-8") - - first = seal_run(root, session_id="test-run") - expected = sha256_file(root / "final-receipt.json") - self.assertEqual( - verify_seal(root, expected_sha256=expected), first - ) - with self.assertRaisesRegex(WorkflowError, "already sealed"): - seal_run(root, session_id="test-run") - - receipt_path = root / "final-receipt.json" - os.chmod(receipt_path, 0o644) - forged = json.loads(receipt_path.read_text(encoding="utf-8")) - forged["artifacts"] = [ - item for item in forged["artifacts"] if item["path"] != "output.log" - ] - receipt_path.write_text(json.dumps(forged), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "read-only|checksum mismatch"): - verify_seal(root, expected_sha256=expected) - - receipt_path.unlink() - seal_run(root, session_id="test-run") - expected = sha256_file(root / "final-receipt.json") - - (root / "output.log").write_text("changed\n", encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "mismatch"): - verify_seal(root, expected_sha256=expected) - - def test_seal_rejects_symlinked_lock_and_receipt_paths(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - write_run_contracts(root) - target = root / "target" - target.touch() - os.symlink(target, root / "seal.lock") - with self.assertRaises(WorkflowError): - seal_run(root, session_id="unsafe-lock") - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - write_run_contracts(root) - target = root / "missing-receipt-target" - os.symlink(target, root / "final-receipt.json") - with self.assertRaisesRegex(WorkflowError, "already sealed or unsafe"): - seal_run(root, session_id="unsafe-receipt") - - def test_make_read_only_covers_optional_sealed_trees(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - assignment = root / "assignments" / "child" / "record.json" - assignment.parent.mkdir(parents=True) - assignment.write_text("{}\n", encoding="utf-8") - make_read_only(root) - self.assertEqual(0, assignment.stat().st_mode & 0o222) - - def test_make_read_only_rejects_symlinks_without_chmodding_target(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - outside = root / "outside.json" - outside.write_text("{}\n", encoding="utf-8") - assignments = root / "assignments" - assignments.mkdir() - os.symlink(outside, assignments / "linked.json") - before = outside.stat().st_mode & 0o777 - with self.assertRaisesRegex(WorkflowError, "symlink"): - make_read_only(root) - self.assertEqual(before, outside.stat().st_mode & 0o777) - - def test_verify_rejects_intermediate_symlink_even_when_target_stays_inside_run(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - write_run_contracts(root) - seal_run(root, session_id="intermediate-link") - original = root / "collections" - target = root / "collections-real" - original.rename(target) - os.symlink(target.name, original) - with self.assertRaisesRegex(WorkflowError, "directory|symlink|open"): - verify_seal(root) - - def test_seal_rejects_missing_required_contracts(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "prompt.md").write_text("ticket\n", encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "missing artifacts"): - seal_run(root, session_id="incomplete") diff --git a/tests/test_regression_eval_fixture.py b/tests/test_regression_eval_fixture.py deleted file mode 100644 index 0c3f42b..0000000 --- a/tests/test_regression_eval_fixture.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import importlib.util -from copy import deepcopy -from pathlib import Path - -import pytest - -from agent_workflow.eval.oracles import scan_for_leak - -ROOT = Path(__file__).parent / "fixtures" / "regression-evals" / "deterministic-json" - - -def _load(path: Path): - spec = importlib.util.spec_from_file_location(f"fixture_{path.stem}", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.normalize_records - - -def _hidden_contract(fn): - source = [ - {"id": " Z ", "nested": {"x": 1}}, - {"id": "z", "nested": {"x": 2}}, - {"id": "A", "value": 3}, - ] - before = deepcopy(source) - result = fn(source) - assert result == [ - {"id": "a", "value": 3}, - {"id": "z", "nested": {"x": 2}}, - ] - assert source == before - return result - - -def test_reference_passes_hidden_contract_and_repeated_score_is_stable(): - fn = _load(ROOT / "app" / "normalize.py") - first = _hidden_contract(fn) - second = _hidden_contract(fn) - assert first == second - - -@pytest.mark.parametrize("mutation", ["no_dedup.py", "mutates_input.py", "first_wins.py"]) -def test_mutations_fail_hidden_contract(mutation): - fn = _load(ROOT / "mutations" / mutation) - with pytest.raises(AssertionError): - _hidden_contract(fn) - - -def test_oracle_canary_is_detected_in_candidate_artifacts(tmp_path): - canary = b"hidden-oracle-canary-94a2" - candidate = tmp_path / "candidate.txt" - candidate.write_bytes(b"prefix " + canary + b" suffix") - assert scan_for_leak(canary, [candidate]) == [str(candidate)] diff --git a/tests/test_release_assets.py b/tests/test_release_assets.py deleted file mode 100644 index 5363fde..0000000 --- a/tests/test_release_assets.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -import importlib.util -import subprocess -import unittest -import tempfile -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -AUDIT_SCRIPT = ROOT / "scripts" / "audit-release-assets.py" - - -def _load_audit_module(): - spec = importlib.util.spec_from_file_location("audit_release_assets", AUDIT_SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -class ReleaseAssetTests(unittest.TestCase): - def test_release_asset_audit_passes(self) -> None: - result = subprocess.run( - ["python3", str(AUDIT_SCRIPT)], - cwd=ROOT, - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_release_files_excludes_git_control_paths(self) -> None: - module = _load_audit_module() - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "keep.txt").write_text("keep", encoding="utf-8") - - git_dir = root / ".git" - git_dir.mkdir() - (git_dir / "config").write_text("config", encoding="utf-8") - (git_dir / "objects").mkdir() - (git_dir / "objects" / "obj").write_text("obj", encoding="utf-8") - - self.assertEqual( - [path.relative_to(root).as_posix() for path in module.release_files(root)], - ["keep.txt"], - ) - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - (root / "keep.txt").write_text("keep", encoding="utf-8") - (root / ".git").write_text("gitdir: /tmp/repo/.git/worktrees/demo\n", encoding="utf-8") - - self.assertEqual( - [path.relative_to(root).as_posix() for path in module.release_files(root)], - ["keep.txt"], - ) - - def test_every_skill_has_yaml_frontmatter(self) -> None: - for path in sorted((ROOT / "skills").glob("*/SKILL.md")): - text = path.read_text(encoding="utf-8") - self.assertTrue(text.startswith("---\n"), path) - self.assertIn("\nname:", text.split("---", 2)[1], path) - self.assertIn("\ndescription:", text.split("---", 2)[1], path) diff --git a/tests/test_routing.py b/tests/test_routing.py deleted file mode 100644 index 31e7071..0000000 --- a/tests/test_routing.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -import unittest -from dataclasses import replace - -from agent_workflow.config import AgentClassPolicy, ExecutorPolicy, defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.routing import advise_routing - - -class RoutingAdviceTests(unittest.TestCase): - def test_task_types_route_to_stable_agent_classes(self): - settings = defaults() - cases = { - "research": ("exploratory", "TASK_EXPLORATORY"), - "discovery": ("exploratory", "TASK_EXPLORATORY"), - "review": ("review", "TASK_REVIEW"), - "security": ("review", "TASK_REVIEW"), - "implementation": ("implementation", "TASK_IMPLEMENTATION"), - "unknown": ("implementation", "TASK_IMPLEMENTATION"), - } - for task_type, (agent_class, code) in cases.items(): - with self.subTest(task_type=task_type): - advice = advise_routing({"task_type": task_type}, settings) - self.assertEqual(advice["recommendation"]["agent_class"], agent_class) - self.assertIn(code, advice["explanation_codes"]) - - def test_interactivity_metadata_is_advisory_and_explained(self): - settings = defaults() - required = advise_routing( - {"task_type": "research", "requires_interaction": True}, settings - ) - self.assertTrue(required["recommendation"]["interactive"]) - self.assertIn("INTERACTION_REQUIRED", required["explanation_codes"]) - disabled = advise_routing( - {"task_type": "implementation", "requires_interaction": False}, settings - ) - self.assertFalse(disabled["recommendation"]["interactive"]) - self.assertIn("INTERACTION_NOT_REQUIRED", disabled["explanation_codes"]) - - def test_enforced_selection_and_policy_disagreement_are_separate(self): - advice = advise_routing( - {"task_type": "review"}, - defaults(), - enforced_selection={"executor": "claude", "model": "sonnet"}, - ) - self.assertEqual(advice["recommendation"]["executor"], "codex") - self.assertEqual(advice["enforced_selection"]["executor"], "claude") - self.assertEqual(advice["policy_disagreements"], ["executor", "model"]) - self.assertIn("ENFORCED_SELECTION_DIFFERS", advice["explanation_codes"]) - - def test_high_risk_implementation_has_stable_explanation(self): - advice = advise_routing( - {"task_type": "implementation", "risk": "critical"}, defaults() - ) - self.assertIn("RISK_HIGH_IMPLEMENTATION", advice["explanation_codes"]) - - def test_no_go_only_model_policy_is_rejected(self): - base = defaults() - settings = replace( - base, - executor_policies={ - **base.executor_policies, - "codex": ExecutorPolicy( - interactive_command=["codex"], - models=("blocked-model",), - default_model="blocked-model", - no_go_models=("blocked-*",), - ), - }, - agent_classes={ - **base.agent_classes, - "review": AgentClassPolicy( - interactive=False, - default_executor="codex", - default_model="blocked-model", - allowed_models={"codex": ("blocked-model",)}, - ), - }, - ) - with self.assertRaisesRegex(WorkflowError, "no non-no-go model"): - advise_routing({"task_type": "review"}, settings) - - def test_input_metadata_is_not_mutated_and_output_is_deterministic(self): - metadata = {"task_type": "research", "requires_interaction": False} - first = advise_routing(metadata, defaults()) - second = advise_routing(metadata, defaults()) - self.assertEqual(first, second) - self.assertEqual(metadata, {"task_type": "research", "requires_interaction": False}) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_runner_generation.py b/tests/test_runner_generation.py deleted file mode 100644 index 63134cd..0000000 --- a/tests/test_runner_generation.py +++ /dev/null @@ -1,408 +0,0 @@ -import shutil -import tempfile -import unittest -import json -from pathlib import Path - -from agent_workflow.eval.scoring import score_trial -from agent_workflow.util import sha256_file -from agent_workflow.runner import MAX_COMPLETION_HANDOFF_BYTES, _capture_patch, _collect_completion -from agent_workflow.sessions import _write_runner -from run_fixtures import write_run_contracts - - -@unittest.skipUnless(shutil.which("bash"), "bash is required") -class RunnerTests(unittest.TestCase): - def test_runner_is_syntax_valid_and_quotes_command(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state = root / "state" - work = root / "work dir" - state.mkdir() - work.mkdir() - (state / "prompt.md").write_text("prompt\n", encoding="utf-8") - (state / "output.log").touch() - (state / "status.json").write_text("{}\n", encoding="utf-8") - runner = _write_runner( - state, work, ["printf", "%s", "hello world"] - ) - self.assertIn( - "'hello world'", runner.read_text(encoding="utf-8") - ) - -class RunnerExecutionTests(unittest.TestCase): - def test_native_job_post_receipts_report_scope_and_command_failures(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work = root / "state", root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="native-post", include_final=False) - handoff = work / ".agent-workflow-handoff" / "native-post" - handoff.mkdir(parents=True) - status_path = state / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status["handoff_dir"] = str(handoff) - status_path.write_text(json.dumps(status), encoding="utf-8") - (state / "evaluation-runtime.json").write_text( - json.dumps( - { - "schema": "agent-workflow/evaluation-runtime/v1", - "acceptance_commands": [ - {"id": "reject", "argv": ["python3", "-c", "raise SystemExit(1)"]} - ], - "scope": { - "writable_paths": ["allowed.txt"], - "disposable_trees": [".agent-workflow-handoff/"], - }, - "scorers": [], - "native_job_binding_sha256": "bound", - } - ), - encoding="utf-8", - ) - from agent_workflow.eval.scope import ScopePolicy, collect_scope - - collect_scope( - work, - phase="baseline", - policy=ScopePolicy(work, writable_paths=("allowed.txt",), disposable_trees=(".agent-workflow-handoff/",)), - receipt_dir=state / "scope", - ) - writer = ( - "import json, os, pathlib; pathlib.Path('outside.txt').write_text('no'); " - "p=pathlib.Path(os.environ['AGENT_WORKFLOW_HANDOFF_DIR']); " - "p.joinpath('completion.json').write_text(json.dumps({'schema':'agent-workflow/completion/v1'," - "'session_id':'native-post','ticket_id':None,'pack_id':None,'result':'completed'," - "'base_revision':None,'head_revision':None,'changed_files':[],'criteria':[],'commands':[]," - "'unresolved':[],'usage':None}))" - ) - runner = _write_runner(state, work, ["python3", "-c", writer], handoff_dir=handoff) - self.assertEqual(subprocess.run([str(runner)], check=False).returncode, 0) - post = json.loads((state / "collections" / "commands-post.json").read_text(encoding="utf-8")) - self.assertEqual(post["commands"][0]["exit_code"], 1) - scope = json.loads((state / "scope" / "scope-post.json").read_text(encoding="utf-8")) - self.assertEqual(scope["policy"]["writable_paths"], ["allowed.txt"]) - scores = score_trial( - state, - output_dir=state / "scores", - expected_final_receipt_sha256=sha256_file(state / "final-receipt.json"), - ) - by_id = {item["scorer"]["id"]: item["verdict"] for item in scores["scores"]} - self.assertEqual(by_id["acceptance_commands"], "fail") - self.assertEqual(by_id["writable_scope"], "fail") - - def test_runner_collects_valid_handoff_before_seal(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work = root / "state", root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="handoff-valid", include_final=False) - handoff = work / ".agent-workflow-handoff" / "handoff-valid" - handoff.mkdir(parents=True) - status_path = state / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status["handoff_dir"] = str(handoff) - status_path.write_text(json.dumps(status), encoding="utf-8") - writer = ( - "import json, os, pathlib; p=pathlib.Path(os.environ['AGENT_WORKFLOW_HANDOFF_DIR']); " - "p.joinpath('completion.json').write_text(json.dumps({'schema':'agent-workflow/completion/v1'," - "'session_id':'handoff-valid','ticket_id':None,'pack_id':None,'result':'completed'," - "'base_revision':None,'head_revision':None,'changed_files':[],'criteria':[],'commands':[]," - "'unresolved':[],'usage':None}))" - ) - runner = _write_runner( - state, work, ["python3", "-c", writer], handoff_dir=handoff - ) - self.assertNotIn("AGENT_WORKFLOW_PROVENANCE_PATH", runner.read_text(encoding="utf-8")) - self.assertEqual(subprocess.run([str(runner)], check=False).returncode, 0) - completion = json.loads((state / "completion.json").read_text(encoding="utf-8")) - collection = json.loads((state / "collections" / "completion.json").read_text(encoding="utf-8")) - self.assertEqual(completion["result"], "completed") - self.assertEqual(collection["validation_status"], "valid") - self.assertEqual(collection["adapter_version"], "1") - self.assertEqual(collection["canonical_mapping"], "identity") - self.assertEqual(collection["canonical_sha256"], collection["source_sha256"]) - final = json.loads((state / "final-receipt.json").read_text(encoding="utf-8")) - self.assertIn("collections/completion.json", {item["path"] for item in final["artifacts"]}) - - def test_invalid_handoff_preserves_placeholder_and_seals(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work = root / "state", root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="handoff-invalid", include_final=False) - handoff = work / ".agent-workflow-handoff" / "handoff-invalid" - handoff.mkdir(parents=True) - (handoff / "completion.json").symlink_to(work / "outside.json") - status_path = state / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status["handoff_dir"] = str(handoff) - status_path.write_text(json.dumps(status), encoding="utf-8") - collection = _collect_completion(state, work) - self.assertEqual(collection["validation_status"], "invalid") - self.assertEqual( - json.loads((state / "completion.json").read_text(encoding="utf-8"))["result"], - "blocked", - ) - - def test_oversized_handoff_is_invalid(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work = root / "state", root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="handoff-large", include_final=False) - handoff = work / ".agent-workflow-handoff" / "handoff-large" - handoff.mkdir(parents=True) - (handoff / "completion.json").write_bytes(b"x" * (MAX_COMPLETION_HANDOFF_BYTES + 1)) - status_path = state / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status["handoff_dir"] = str(handoff) - status_path.write_text(json.dumps(status), encoding="utf-8") - self.assertEqual(_collect_completion(state, work)["validation_status"], "invalid") - - def test_missing_malformed_and_escaping_handoffs_seal_with_receipts(self): - import subprocess - - cases = (("missing", "missing"), ("malformed", "invalid"), ("escape", "invalid")) - for name, expected in cases: - with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work = root / "state", root / "work" - state.mkdir() - work.mkdir() - session_id = f"handoff-{name}" - write_run_contracts(state, session_id=session_id, include_final=False) - handoff = work / ".agent-workflow-handoff" / session_id - handoff.mkdir(parents=True) - if name == "malformed": - (handoff / "completion.json").write_text("{not json", encoding="utf-8") - if name == "escape": - handoff = state - status_path = state / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status["handoff_dir"] = str(handoff) - status_path.write_text(json.dumps(status), encoding="utf-8") - runner = _write_runner(state, work, ["true"]) - self.assertEqual(subprocess.run([str(runner)], check=False).returncode, 0) - collection = json.loads( - (state / "collections" / "completion.json").read_text(encoding="utf-8") - ) - self.assertEqual(collection["validation_status"], expected) - self.assertIsNone(collection["canonical_mapping"]) - self.assertIsNone(collection["canonical_sha256"]) - completion = json.loads((state / "completion.json").read_text(encoding="utf-8")) - self.assertEqual(completion["result"], "blocked") - self.assertTrue((state / "final-receipt.json").is_file()) - - def test_missing_executor_fails_and_seals_evidence(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state = root / "state" - work = root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="missing-executor", include_final=False) - runner = _write_runner(state, work, ["definitely-not-an-executor"]) - result = subprocess.run([str(runner)], check=False) - status = json.loads((state / "status.json").read_text(encoding="utf-8")) - self.assertEqual(result.returncode, 127) - self.assertEqual(status["status"], "failed") - self.assertEqual(status["failure_category"], "command_not_found") - self.assertTrue((state / "final-receipt.json").is_file()) - - def test_patch_captures_committed_and_untracked_agent_changes(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - work = root / "work" - state = root / "state" - work.mkdir() - state.mkdir() - subprocess.run(["git", "init", "-q", str(work)], check=True) - subprocess.run(["git", "-C", str(work), "config", "user.name", "Test"], check=True) - subprocess.run(["git", "-C", str(work), "config", "user.email", "test@example.test"], check=True) - tracked = work / "tracked.txt" - tracked.write_text("base\n", encoding="utf-8") - subprocess.run(["git", "-C", str(work), "add", "tracked.txt"], check=True) - subprocess.run(["git", "-C", str(work), "commit", "-qm", "base"], check=True) - base = subprocess.run( - ["git", "-C", str(work), "rev-parse", "HEAD"], - check=True, - capture_output=True, - text=True, - ).stdout.strip() - (state / "source-baseline.json").write_text( - json.dumps({"components": {"primary": {"head": base}}}), - encoding="utf-8", - ) - tracked.write_text("committed\n", encoding="utf-8") - subprocess.run(["git", "-C", str(work), "commit", "-qam", "agent"], check=True) - (work / "new.txt").write_text("untracked\n", encoding="utf-8") - patch_path = state / "patch.diff" - _capture_patch(work, state, patch_path) - content = patch_path.read_text(encoding="utf-8") - self.assertIn("tracked.txt", content) - self.assertIn("new.txt", content) - - def test_runner_enforces_timeout_and_seals_failure(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state = root / "state" - work = root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="timeout-test", include_final=False) - (state / "evaluation-runtime.json").write_text( - json.dumps( - { - "schema": "agent-workflow/evaluation-runtime/v1", - "timeout_seconds": 0.2, - "scope": {}, - "acceptance_commands": [], - } - ), - encoding="utf-8", - ) - runner = _write_runner( - state, - work, - ["python3", "-c", "import time; time.sleep(10)"], - ) - result = subprocess.run([str(runner)], check=False) - status = json.loads((state / "status.json").read_text(encoding="utf-8")) - self.assertEqual(result.returncode, 124) - self.assertEqual(status["status"], "failed") - self.assertEqual(status["failure_category"], "timeout") - self.assertTrue((state / "final-receipt.json").is_file()) - - def test_runner_enforces_reported_token_and_cost_budgets(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state = root / "state" - work = root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="budget-test", include_final=False) - provenance_path = state / "run-provenance.json" - provenance = json.loads(provenance_path.read_text(encoding="utf-8")) - provenance["budgets"] = { - "max_output_tokens": 1, - "max_cost": 0.1, - "currency": "USD", - } - provenance_path.write_text(json.dumps(provenance), encoding="utf-8") - event = json.dumps( - { - "type": "turn.completed", - "usage": { - "output_tokens": 2, - "provider_billed_cost": 0.2, - "currency": "USD", - } - } - ) - runner = _write_runner( - state, - work, - ["python3", "-c", f"print({event!r})"], - stream_format="codex-jsonl", - ) - result = subprocess.run([str(runner)], check=False) - status = json.loads((state / "status.json").read_text(encoding="utf-8")) - self.assertEqual(result.returncode, 1) - self.assertEqual(status["failure_category"], "budget_exhausted") - self.assertEqual(len(status["budget_exceeded"]), 2) - - def test_runner_records_success_and_log(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state = root / "state" - work = root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="runner-test", include_final=False) - (state / "prompt.md").write_text("hello prompt\n", encoding="utf-8") - (state / "launch-prompt.md").write_text("hello prompt\n", encoding="utf-8") - runner = _write_runner( - state, - work, - [ - "python3", - "-c", - "import sys; print(sys.stdin.read().strip())", - ], - ) - result = subprocess.run([str(runner)], check=False) - self.assertEqual(result.returncode, 0) - status = json.loads((state / "status.json").read_text(encoding="utf-8")) - self.assertEqual(status["status"], "completed") - self.assertEqual(status["exit_code"], 0) - self.assertIn( - "hello prompt", - (state / "output.log").read_text(encoding="utf-8"), - ) - - def test_runner_mirrors_normalized_structured_output_to_terminal(self): - import subprocess - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work = root / "state", root / "work" - state.mkdir() - work.mkdir() - write_run_contracts(state, session_id="visible-output", include_final=False) - event = json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "pane-visible"}]}, - } - ) - runner = _write_runner( - state, - work, - ["python3", "-c", f"print({event!r})"], - stream_format="claude-stream-json", - ) - result = subprocess.run([str(runner)], check=False, capture_output=True, text=True) - self.assertEqual(result.returncode, 0) - self.assertIn("pane-visible", result.stdout) - self.assertNotIn('"type": "assistant"', result.stdout) - - -class StructuredTaskResultTests(unittest.TestCase): - def test_collects_declared_structured_result(self): - from agent_workflow.runner import _collect_task_result - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - state, work, pack = root / "state", root / "work", root / "pack" - state.mkdir(); work.mkdir(); pack.mkdir() - write_run_contracts(state, session_id="result-valid", include_final=False) - schema = {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["value"],"properties":{"value":{"type":"integer"}},"additionalProperties":False} - (pack / "result.schema.json").write_text(json.dumps(schema), encoding="utf-8") - handoff = work / ".agent-workflow-handoff" / "result-valid" - handoff.mkdir(parents=True) - (handoff / "result.json").write_text('{"value":7}', encoding="utf-8") - status_path = state / "status.json" - status = json.loads(status_path.read_text(encoding="utf-8")) - status.update({"handoff_dir":str(handoff), "prompt_pack_root":str(pack), "result_contract":{"schema":"result.schema.json","required":True}}) - status_path.write_text(json.dumps(status), encoding="utf-8") - receipt = _collect_task_result(state, work) - self.assertEqual(receipt["validation_status"], "valid") - self.assertEqual(json.loads((state / "result.json").read_text())["value"], 7) diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py deleted file mode 100644 index 45d2d78..0000000 --- a/tests/test_scaffold.py +++ /dev/null @@ -1,22 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.manifests import validate_pack -from agent_workflow.pack import scaffold - - -class ScaffoldTests(unittest.TestCase): - def test_scaffold_creates_valid_three_phase_pack(self): - with tempfile.TemporaryDirectory() as tmp: - destination = Path(tmp) / "sample-pack" - scaffold(destination, 3, "Sample Pack") - report = validate_pack(destination) - self.assertTrue(report.ok, report.errors) - self.assertEqual(report.phases, 3) - self.assertTrue( - (destination / "phase-2/tickets/P2-00-baseline-and-preflight.md").is_file() - ) - self.assertTrue( - (destination / "scripts/launch-delegation.sh").stat().st_mode & 0o111 - ) diff --git a/tests/test_session_launch.py b/tests/test_session_launch.py deleted file mode 100644 index 2269ec1..0000000 --- a/tests/test_session_launch.py +++ /dev/null @@ -1,713 +0,0 @@ -import hashlib -import json -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.config import AgentProfile, defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.sessions import _resolve_agent_identity, kill, launch, restart -from agent_workflow.state import write_status -from agent_workflow.tmux import PaneInfo - - -class SessionLaunchTests(unittest.TestCase): - def setUp(self): - # Unit launches must not mutate the developer's ambient tmux window. - self._tmux_target = patch( - "agent_workflow.sessions.tmux.current_window_target", return_value=None - ) - self._tmux_target.start() - self.addCleanup(self._tmux_target.stop) - - def test_agent_classes_set_executor_model_and_visibility(self): - settings = defaults(Path("/missing.toml")) - with patch("agent_workflow.sessions.list_statuses", return_value=[]): - exploratory = _resolve_agent_identity( - settings, - requested_name=None, - requested_class="exploratory", - executor=None, - model=None, - allow_no_go_model=False, - explicit_command=None, - interactive=None, - ) - implementation = _resolve_agent_identity( - settings, - requested_name=None, - requested_class="implementation", - executor=None, - model=None, - allow_no_go_model=False, - explicit_command=None, - interactive=None, - ) - self.assertEqual(exploratory[1:4], ("exploratory", "claude", "haiku")) - self.assertTrue(exploratory[-1]) - self.assertEqual( - implementation[1:4], - ("implementation", "codex", "gpt-5.4-mini"), - ) - self.assertTrue(implementation[-1]) - - def test_claude_defaults_interactive_but_explicit_opt_out_is_honored(self): - settings = defaults(Path("/missing.toml")) - with patch("agent_workflow.sessions.list_statuses", return_value=[]): - default = _resolve_agent_identity( - settings, - requested_name=None, - requested_class="review", - executor="claude", - model="haiku", - allow_no_go_model=False, - explicit_command=None, - interactive=None, - ) - opted_out = _resolve_agent_identity( - settings, - requested_name=None, - requested_class="review", - executor="claude", - model="haiku", - allow_no_go_model=False, - explicit_command=None, - interactive=False, - ) - self.assertTrue(default[-1]) - self.assertFalse(opted_out[-1]) - - def test_agent_name_is_unique_across_interactive_and_detached_runs(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - prompt = root / "task.md" - prompt.write_text("task", encoding="utf-8") - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.create_session"), - patch( - "agent_workflow.sessions.tmux.pane_info", - return_value=PaneInfo(pid=123, dead=False, command="cat"), - ), - ): - launch( - settings, session_id="detached-one", workdir=workdir, - prompt_path=prompt, explicit_command=["cat"], - agent_name="agent-42", interactive=False, - ) - with self.assertRaisesRegex(WorkflowError, "agent name is already active"): - launch( - settings, session_id="interactive-two", workdir=workdir, - prompt_path=prompt, explicit_command=["cat"], - agent_name="agent-42", interactive=True, - ) - - def test_workflow_inputs_are_copied_read_only_before_executor_launch(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - prompt = root / "task.md" - prompt.write_text("task", encoding="utf-8") - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - context = { - "schema": "agent-workflow/workflow-input-bindings/v1", - "workflow_id": "wf-1", - "snapshot_sha256": "a" * 64, - "node_id": "node-1", - "attempt": 1, - "created_at": "2026-07-24T00:00:00+00:00", - "bindings": [], - } - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.create_session"), - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - ): - result = launch( - settings, - session_id="workflow-input-copy", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - workflow_context=context, - ) - copied = Path(result["prompt_path"]).parent / "workflow-inputs.json" - self.assertEqual(context, json.loads(copied.read_text(encoding="utf-8"))) - self.assertEqual(0, copied.stat().st_mode & 0o222) - - def test_named_agent_profile_binds_executor_model_and_class(self): - base = defaults(Path("/missing.toml")) - settings = base.__class__( - **{ - **base.__dict__, - "preferred_agent_names": ("larry", "moe", "curly"), - "agent_profiles": { - "moe": AgentProfile("codex", "gpt-5.6-luna", agent_class="implementation"), - "curly": AgentProfile("claude", "haiku", agent_class="implementation"), - }, - } - ) - active = [{"agent_name": "larry", "status": "running"}] - with patch("agent_workflow.sessions.list_statuses", return_value=active): - moe = _resolve_agent_identity( - settings, - requested_name=None, - requested_class=None, - executor=None, - model=None, - allow_no_go_model=False, - explicit_command=None, - interactive=None, - ) - self.assertEqual(moe[:4], ("moe", "implementation", "codex", "gpt-5.6-luna")) - - def test_agent_class_rejects_disallowed_model(self): - with ( - patch("agent_workflow.sessions.list_statuses", return_value=[]), - self.assertRaisesRegex(WorkflowError, "does not allow"), - ): - _resolve_agent_identity( - defaults(Path("/missing.toml")), - requested_name=None, - requested_class="exploratory", - executor="claude", - model="sonnet", - allow_no_go_model=False, - explicit_command=None, - interactive=None, - ) - - def _native_job_launch_inputs(self, root: Path) -> tuple[Path, Path, Path]: - pack = root / "pack" - workdir = pack / "worktrees" / "P0-01" - workdir.mkdir(parents=True) - (pack / "pack.yaml").write_text( - "schema: agent-workflow/pack/v1\npack_id: native-pack\n", - encoding="utf-8", - ) - prompt = pack / "prompts" / "P0-01.md" - prompt.parent.mkdir() - prompt.write_text("# native task\n", encoding="utf-8") - job = pack / "jobs" / "P0-01.json" - job.parent.mkdir() - job.write_text( - json.dumps( - { - "schema": "agent-workflow/native-job/v1", - "job_id": "native-P0-01", - "ticket_id": "P0-01", - "prompt_path": "prompts/P0-01.md", - "worktree_target": "worktrees/P0-01", - "path_policy": { - "allowed_paths": ["src/example.py"], - "forbidden_paths": ["secrets"], - }, - "acceptance_commands": [ - {"id": "unit", "argv": ["python3", "-c", "pass"]} - ], - "review_requirement": {"required": True, "independent": True}, - } - ), - encoding="utf-8", - ) - return workdir, prompt, job - - def test_launch_uses_visible_current_tmux_window_or_detached_fallback(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir, prompt, job = self._native_job_launch_inputs(root) - settings = defaults(root / "missing.toml") - settings = settings.__class__(**{**settings.__dict__, "state_root": root / "state"}) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value="parent:1"), - patch("agent_workflow.sessions.tmux.interactive_pane_count", return_value=1), - patch("agent_workflow.sessions.tmux.split_window", return_value="parent:1.3") as split, - patch("agent_workflow.sessions.tmux.create_session") as create, - patch("agent_workflow.sessions.tmux.pane_info", return_value=PaneInfo(123, False, "python3")), - ): - result = launch( - settings, session_id="visible-child", workdir=workdir, - prompt_path=prompt, explicit_command=["cat"], job_path=job, - ticket_id="P0-01", pack_id="native-pack", - ) - split.assert_called_once() - self.assertEqual(split.call_args.kwargs["max_interactive_agent_width"], 2) - self.assertEqual(split.call_args.kwargs["max_interactive_agent_vertical"], 3) - create.assert_not_called() - self.assertEqual("shared_window", result["tmux_mode"]) - self.assertEqual("parent", result["tmux_session"]) - self.assertEqual("parent:1.3", result["tmux_target"]) - - workdir2, prompt2, job2 = self._native_job_launch_inputs(root / "fallback") - settings2 = defaults(root / "fallback.toml") - settings2 = settings2.__class__(**{**settings2.__dict__, "state_root": root / "state2"}) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value=None), - patch("agent_workflow.sessions.tmux.create_session") as fallback_create, - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - ): - fallback = launch( - settings2, session_id="detached-child", workdir=workdir2, - prompt_path=prompt2, explicit_command=["cat"], job_path=job2, - ticket_id="P0-01", pack_id="native-pack", - ) - fallback_create.assert_called_once() - self.assertEqual("dedicated_session", fallback["tmux_mode"]) - self.assertEqual("detached-child", fallback["tmux_target"]) - - def test_native_job_preflight_failures_create_no_state_or_tmux(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir, prompt, job = self._native_job_launch_inputs(root) - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - cases = [ - ("ticket", {"ticket_id": "P9-99"}, "--ticket disagrees"), - ("pack", {"pack_id": "other-pack"}, "--pack disagrees"), - ("worktree", {"workdir": root / "elsewhere"}, "worktree_target disagrees"), - ] - for suffix, overrides, message in cases: - if "workdir" in overrides: - overrides["workdir"].mkdir() - with patch("agent_workflow.sessions.tmux.create_session") as create_session: - with self.assertRaisesRegex(WorkflowError, message): - launch( - settings, - session_id=f"native-fail-{suffix}", - workdir=overrides.get("workdir", workdir), - prompt_path=prompt, - explicit_command=["cat"], - job_path=job, - ticket_id=overrides.get("ticket_id"), - pack_id=overrides.get("pack_id"), - ) - self.assertFalse((root / "state" / "runs" / f"native-fail-{suffix}").exists()) - create_session.assert_not_called() - - outside = root / "outside.json" - outside.write_bytes(job.read_bytes()) - with patch("agent_workflow.sessions.tmux.create_session") as create_session: - with self.assertRaisesRegex(WorkflowError, "outside pack root"): - launch( - settings, - session_id="native-fail-outside", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - job_path=outside, - ) - self.assertFalse((root / "state" / "runs" / "native-fail-outside").exists()) - create_session.assert_not_called() - - escaped = root / "pack" / "jobs" / "escaped.json" - escaped_value = json.loads(job.read_text(encoding="utf-8")) - escaped_value["prompt_path"] = "../outside.md" - escaped.write_text(json.dumps(escaped_value), encoding="utf-8") - with patch("agent_workflow.sessions.tmux.create_session") as create_session: - with self.assertRaisesRegex(WorkflowError, "prompt_path must be a relative"): - launch( - settings, - session_id="native-fail-prompt-escape", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - job_path=escaped, - ) - self.assertFalse( - (root / "state" / "runs" / "native-fail-prompt-escape").exists() - ) - create_session.assert_not_called() - - def test_native_job_binding_snapshots_raw_bytes_and_status_provenance(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir, prompt, job = self._native_job_launch_inputs(root) - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value=None), - patch("agent_workflow.sessions.tmux.create_session") as create_session, - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - ): - result = launch( - settings, - session_id="native-bound", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - job_path=job, - ticket_id="P0-01", - pack_id="native-pack", - ) - run = Path(result["prompt_path"]).parent - binding = json.loads((run / "job-binding.json").read_text(encoding="utf-8")) - stored = Path(binding["job_stored_path"]) - self.assertEqual(stored.read_bytes(), job.read_bytes()) - self.assertEqual(binding["job_source_sha256"], hashlib.sha256(job.read_bytes()).hexdigest()) - self.assertEqual(binding["job_stored_sha256"], binding["job_source_sha256"]) - self.assertEqual(0, stored.stat().st_mode & 0o222) - self.assertEqual(0, (run / "job-binding.json").stat().st_mode & 0o222) - self.assertEqual(binding["path_policy"]["allowed_paths"], ["src/example.py"]) - self.assertTrue(binding["review_requirement"]["required"]) - status = json.loads((run / "status.json").read_text(encoding="utf-8")) - provenance = json.loads((run / "run-provenance.json").read_text(encoding="utf-8")) - self.assertEqual(status["job_id"], "native-P0-01") - self.assertEqual(status["job_binding_path"], str(run / "job-binding.json")) - self.assertEqual(provenance["job_binding"]["sha256"], status["job_binding_sha256"]) - runtime = json.loads((run / "evaluation-runtime.json").read_text(encoding="utf-8")) - self.assertEqual(runtime["scope"]["writable_paths"], ["src/example.py"]) - self.assertEqual(runtime["acceptance_commands"][0]["argv"], ["python3", "-c", "pass"]) - self.assertTrue((run / "scope" / "scope-baseline.json").is_file()) - baseline_commands = json.loads( - (run / "collections" / "commands-baseline.json").read_text(encoding="utf-8") - ) - self.assertEqual(baseline_commands["commands"][0]["exit_code"], 0) - create_session.assert_called_once() - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value=None), - patch("agent_workflow.sessions.tmux.create_session"), - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - ): - retry = restart(settings, "native-bound", "native-bound-retry") - retry_binding = json.loads( - (Path(retry["prompt_path"]).parent / "job-binding.json").read_text( - encoding="utf-8" - ) - ) - self.assertEqual(retry_binding["job_source_sha256"], binding["job_source_sha256"]) - def test_restart_preserves_structured_executor_and_prompt_source(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - pack = root / "pack" - prompt = pack / "prompts" / "ticket.md" - prompt.parent.mkdir(parents=True) - (pack / "pack.yaml").write_text("id: p\n", encoding="utf-8") - prompt.write_text("task\n", encoding="utf-8") - evaluation = pack / "evals" / "evaluation.json" - evaluation.parent.mkdir() - evaluation.write_text( - json.dumps( - { - "schema": "agent-workflow/evaluation-plan/v1", - "dataset_split": "development", - "task_ids": ["P0-01"], - "repetitions": 1, - "timeout_seconds": 30, - "scorers": ["writable_scope"], - "sandbox": "docker", - "budgets": {"max_output_tokens": 100}, - "scope": {"writable_trees": ["src/"]}, - } - ), - encoding="utf-8", - ) - settings = defaults(root / "missing.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.create_session"), - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - patch("agent_workflow.sessions.executor_version", return_value="test"), - patch("agent_workflow.sessions.shutil.which", return_value="/usr/bin/codex"), - ): - launch( - settings, - session_id="structured", - workdir=workdir, - prompt_path=prompt, - executor="codex", - structured=True, - ticket_id="P0-01", - evaluation_path=evaluation, - ) - retry = restart(settings, "structured", "structured-retry") - - command = json.loads(Path(retry["command_path"]).read_text(encoding="utf-8")) - self.assertEqual(command["executor"], "codex") - self.assertEqual(command["stream_format"], "codex-jsonl") - self.assertEqual(retry["prompt_source"], str(prompt)) - self.assertEqual(retry["prompt_pack_root"], str(pack)) - retry_run = Path(retry["prompt_path"]).parent - self.assertTrue((retry_run / "evaluation-runtime.json").is_file()) - provenance = json.loads( - (retry_run / "run-provenance.json").read_text(encoding="utf-8") - ) - self.assertEqual(provenance["budgets"]["max_output_tokens"], 100) - - def test_launch_prepares_durable_evidence_and_worktree_link(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - prompt = root / "ticket.md" - prompt.write_text("# Ticket\n", encoding="utf-8") - settings = defaults(root / "missing-config.toml") - settings = settings.__class__( - **{ - **settings.__dict__, - "state_root": root / "state", - } - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value=None), - patch("agent_workflow.sessions.tmux.create_session") as create_session, - patch( - "agent_workflow.sessions.tmux.pane_info", - return_value=PaneInfo(pid=1234, dead=False, command="bash"), - ), - ): - result = launch( - settings, - session_id="sample-p0-01", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - ticket_id="P0-01", - pack_id="sample-pack", - ) - run_dir = root / "state" / "runs" / "sample-p0-01" - self.assertEqual(result["status"], "launched") - self.assertTrue((run_dir / "prompt.md").is_file()) - self.assertTrue((run_dir / "source-baseline.json").is_file()) - self.assertFalse((run_dir / "evaluation-runtime.json").exists()) - self.assertFalse((run_dir / "scope" / "scope-baseline.json").exists()) - self.assertFalse((run_dir / "collections" / "commands-baseline.json").exists()) - self.assertTrue((run_dir / "completion.md").is_file()) - self.assertTrue((run_dir / "completion.json").is_file()) - self.assertTrue((run_dir / "run-provenance.json").is_file()) - self.assertTrue((run_dir / "executor-events.jsonl").is_file()) - self.assertEqual( - (workdir / ".delegations" / "sample-p0-01").resolve(), - run_dir.resolve(), - ) - handoff = workdir / ".agent-workflow-handoff" / "sample-p0-01" - self.assertTrue(handoff.is_dir()) - self.assertFalse(handoff.is_symlink()) - self.assertFalse(handoff.resolve().is_relative_to(run_dir.resolve())) - self.assertEqual(result["handoff_dir"], str(handoff.resolve())) - create_session.assert_called_once() - - def test_explicit_codex_launch_records_structured_executor(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - prompt = root / "ticket.md" - prompt.write_text("# Ticket\n", encoding="utf-8") - codex = root / "codex" - codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - codex.chmod(0o755) - settings = defaults(root / "missing-config.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.create_session"), - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - patch("agent_workflow.sessions.executor_version", return_value="test"), - ): - result = launch( - settings, - session_id="explicit-codex", - workdir=workdir, - prompt_path=prompt, - explicit_command=[str(codex), "exec", "-"], - structured=True, - ) - command = json.loads(Path(result["command_path"]).read_text(encoding="utf-8")) - provenance = json.loads( - (Path(result["command_path"]).parent / "run-provenance.json").read_text( - encoding="utf-8" - ) - ) - self.assertEqual(command["executor"], "codex") - self.assertEqual(command["stream_format"], "codex-jsonl") - self.assertEqual( - command["argv"], - [str(codex), "exec", "--model", "gpt-5.4-mini", "--json", "-"], - ) - self.assertEqual(provenance["model"], "gpt-5.4-mini") - self.assertFalse(provenance["model_policy"]["no_go_authorized"]) - self.assertEqual(provenance["executor"], "codex") - self.assertEqual(provenance["stream_format"], "codex-jsonl") - - def test_launch_process_receives_durable_task_context(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - pack = root / "prompt-pack" - prompt = pack / "prompts" / "ticket.md" - prompt.parent.mkdir(parents=True) - (pack / "pack.yaml").write_text("id: sample-pack\n", encoding="utf-8") - prompt.write_text("# Original ticket\n", encoding="utf-8") - result_path = root / "received.json" - receiver = ( - "import json, os, pathlib, sys; " - "handoff = pathlib.Path(os.environ['AGENT_WORKFLOW_HANDOFF_DIR']); " - "handoff.joinpath('completion.json').write_text(json.dumps({" - "'schema': 'agent-workflow/completion/v1', " - "'session_id': os.environ['AGENT_WORKFLOW_SESSION_ID'], " - "'ticket_id': None, 'pack_id': None, 'result': 'completed', " - "'base_revision': None, 'head_revision': None, 'changed_files': [], " - "'criteria': [], 'commands': [], 'unresolved': [], 'usage': None})); " - f"pathlib.Path({str(result_path)!r}).write_text(json.dumps({{" - "'session_id': os.environ['AGENT_WORKFLOW_SESSION_ID'], " - "'prompt_source': os.environ['AGENT_WORKFLOW_PROMPT_SOURCE'], " - "'pack_root': os.environ['AGENT_WORKFLOW_PROMPT_PACK_ROOT'], " - "'handoff_dir': os.environ['AGENT_WORKFLOW_HANDOFF_DIR'], " - "'stdin': sys.stdin.read()}, sort_keys=True))" - ) - settings = defaults(root / "missing-config.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.create_session") as create_session, - patch("agent_workflow.sessions.tmux.pane_info", return_value=None), - ): - result = launch( - settings, - session_id="context-p0-01", - workdir=workdir, - prompt_path=prompt, - explicit_command=["python3", "-c", receiver], - interactive=False, - ) - runner = Path(create_session.call_args.args[2]) - subprocess.run([str(runner)], check=True, capture_output=True, text=True) - received = json.loads(result_path.read_text(encoding="utf-8")) - - self.assertEqual(received["session_id"], "context-p0-01") - self.assertEqual(received["prompt_source"], str(prompt)) - self.assertEqual(received["pack_root"], str(pack)) - self.assertEqual(received["handoff_dir"], result["handoff_dir"]) - self.assertTrue(Path(received["handoff_dir"]).is_dir()) - self.assertFalse( - Path(received["handoff_dir"]).is_relative_to( - Path(result["completion_json_path"]).parent - ) - ) - self.assertIn("session_id: `context-p0-01`", received["stdin"]) - self.assertIn("# Original ticket", received["stdin"]) - self.assertEqual( - result["prompt_sha256"], - hashlib.sha256(prompt.read_bytes()).hexdigest(), - ) - self.assertTrue((Path(result["completion_json_path"])).is_file()) - final_status = json.loads( - (Path(result["prompt_path"]).parent / "status.json").read_text( - encoding="utf-8" - ) - ) - self.assertTrue(Path(final_status["final_receipt_path"]).is_file()) - self.assertEqual(final_status["completion_validation_status"], "valid") - completion = json.loads(Path(result["completion_json_path"]).read_text(encoding="utf-8")) - self.assertEqual(completion["result"], "completed") - - def test_kill_preserves_terminal_durable_status(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = defaults(root / "missing-config.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - write_status( - settings, - "finished-p0-01", - {"session_id": "finished-p0-01", "status": "completed", "exit_code": 0}, - ) - with patch("agent_workflow.sessions.tmux.session_exists", return_value=False): - result = kill(settings, "finished-p0-01") - - self.assertEqual(result["status"], "completed") - self.assertEqual(result["exit_code"], 0) - self.assertNotIn("killed_by_operator", result) - - @unittest.skipUnless(shutil.which("git"), "git is required") - def test_dirty_git_worktree_requires_explicit_override(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - workdir = root / "worktree" - workdir.mkdir() - subprocess.run(["git", "init", "-q", str(workdir)], check=True) - subprocess.run( - ["git", "-C", str(workdir), "config", "user.email", "test@example.test"], - check=True, - ) - subprocess.run( - ["git", "-C", str(workdir), "config", "user.name", "Test"], - check=True, - ) - tracked = workdir / "tracked.txt" - tracked.write_text("clean\n", encoding="utf-8") - subprocess.run(["git", "-C", str(workdir), "add", "tracked.txt"], check=True) - subprocess.run( - ["git", "-C", str(workdir), "commit", "-qm", "initial"], - check=True, - ) - tracked.write_text("dirty\n", encoding="utf-8") - prompt = root / "ticket.md" - prompt.write_text("# Ticket\n", encoding="utf-8") - settings = defaults(root / "missing-config.toml") - settings = settings.__class__( - **{**settings.__dict__, "state_root": root / "state"} - ) - with patch( - "agent_workflow.sessions.tmux.session_exists", return_value=False - ): - with self.assertRaisesRegex(WorkflowError, "worktree is dirty"): - launch( - settings, - session_id="dirty-p0-01", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - ) - - with ( - patch("agent_workflow.sessions.tmux.session_exists", return_value=False), - patch("agent_workflow.sessions.tmux.current_window_target", return_value=None), - patch("agent_workflow.sessions.tmux.create_session"), - patch( - "agent_workflow.sessions.tmux.pane_info", - return_value=PaneInfo(pid=5678, dead=False, command="bash"), - ), - ): - result = launch( - settings, - session_id="dirty-p0-01-allowed", - workdir=workdir, - prompt_path=prompt, - explicit_command=["cat"], - allow_dirty=True, - ) - self.assertTrue(result["dirty_at_launch"]) diff --git a/tests/test_skill_contracts.py b/tests/test_skill_contracts.py deleted file mode 100644 index 9fc26ee..0000000 --- a/tests/test_skill_contracts.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -import re -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SKILLS = ROOT / "skills" - - -class SkillContractTests(unittest.TestCase): - def test_all_workflow_skills_have_metadata_and_orchestrator_link(self) -> None: - expected = { - "agent-workflow-orchestrator", - "delegated-implementation", - "phase-gate-review", - "prompt-pack-builder", - } - self.assertEqual({p.parent.name for p in SKILLS.glob("*/SKILL.md")}, expected) - for name in expected: - text = (SKILLS / name / "SKILL.md").read_text(encoding="utf-8") - self.assertRegex(text, rf"(?m)^name: {re.escape(name)}$") - self.assertRegex(text, r"(?m)^description: .+$") - if name != "agent-workflow-orchestrator": - self.assertIn("agent-workflow-orchestrator", text) - - def test_orchestrator_preserves_canonical_lifecycle_claims(self) -> None: - text = (SKILLS / "agent-workflow-orchestrator" / "SKILL.md").read_text(encoding="utf-8") - for command in ( - "agent-workflow doctor", - "agent-workflow pack validate", - "agent-workflow worktree create", - "agent-workflow launch", - "agent-workflow status", - "agent-workflow watch", - "agent-workflow steer", - "agent-workflow progress", - "agent-workflow ack", - "agent-workflow interrupt", - "agent-workflow terminate", - "agent-workflow review", - "agent-workflow accept", - ): - self.assertIn(command, text) - self.assertIn("visible pane", text) - self.assertIn("detached named session", text) - self.assertIn("host-native subagent is not", text) - self.assertNotIn("tmux new-session", text) - self.assertNotIn("tmux split-window", text) diff --git a/tests/test_tmux.py b/tests/test_tmux.py deleted file mode 100644 index e6bfa96..0000000 --- a/tests/test_tmux.py +++ /dev/null @@ -1,194 +0,0 @@ -import subprocess -import tempfile -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from agent_workflow.errors import WorkflowError -from agent_workflow.tmux import ( - configure_server, - current_window_target, - signal_waiters, - split_window, - ensure_interactive_capacity, - wait_for_wakeup, - wakeup_channel, -) - - -class TmuxWakeupTests(unittest.TestCase): - def test_configure_server_enables_mouse(self): - with patch("agent_workflow.tmux.ensure_tmux"), patch("agent_workflow.tmux.run") as run: - configure_server(mouse=True) - self.assertEqual(run.call_args_list[0].args[0], ["tmux", "set-option", "-g", "mouse", "on"]) - self.assertEqual(run.call_args_list[1].args[0][-2:], ["pane-border-status", "top"]) - - def test_channels_are_stable_and_do_not_disclose_paths(self): - with tempfile.TemporaryDirectory() as tmp: - first = Path(tmp) / "one" - second = Path(tmp) / "two" - self.assertEqual(wakeup_channel(first), wakeup_channel(first / ".." / "one")) - self.assertNotEqual(wakeup_channel(first), wakeup_channel(second)) - self.assertNotIn(str(first), wakeup_channel(first)) - - def test_signal_is_best_effort(self): - with patch("agent_workflow.tmux.run", side_effect=Exception("missing")): - # Only WorkflowError is expected from the production wrapper. - # A generic exception must still surface programming defects. - with self.assertRaises(Exception): - signal_waiters("channel") - with patch("agent_workflow.tmux.run", side_effect=__import__("agent_workflow.errors", fromlist=["WorkflowError"]).WorkflowError("missing")): - signal_waiters("channel") - - def test_wait_is_bounded_for_timeout_and_unavailable_tmux(self): - process = Mock() - process.wait.side_effect = [subprocess.TimeoutExpired(["tmux"], 0.1), 0] - with patch("agent_workflow.tmux.subprocess.Popen", return_value=process): - self.assertFalse(wait_for_wakeup("channel", 0.1)) - process.kill.assert_called_once() - with patch("agent_workflow.tmux.subprocess.Popen", side_effect=OSError("missing")): - self.assertFalse(wait_for_wakeup("channel", 0.1)) - - def test_current_window_requires_tmux_environment_and_valid_response(self): - with patch.dict("agent_workflow.tmux.os.environ", {}, clear=True): - self.assertIsNone(current_window_target()) - result = Mock(returncode=0, stdout="parent:2\n") - with patch.dict("agent_workflow.tmux.os.environ", {"TMUX": "socket"}, clear=True), patch("agent_workflow.tmux.run", return_value=result): - self.assertEqual("parent:2", current_window_target()) - - def test_split_window_requests_right_side_pane(self): - listing = Mock(returncode=0, stdout="%1\t\t0\t0\t0\t\n") - created = Mock(returncode=0, stdout="parent:2.4\n") - ok = Mock(returncode=0, stdout="") - with patch.dict("agent_workflow.tmux.os.environ", {"TMUX_PANE": "%1"}, clear=True), patch( - "agent_workflow.tmux.run", side_effect=[listing, ok, created, ok, ok, ok, ok, ok] - ) as run: - self.assertEqual("parent:2.4", split_window("parent:2", "/tmp/work", "/tmp/runner")) - self.assertEqual( - run.call_args_list[2].args[0], - [ - "tmux", - "split-window", - "-h", - "-d", - "-P", - "-F", - "#{session_name}:#{window_index}.#{pane_index}", - "-t", - "%1", - "-c", - "/tmp/work", - "/tmp/runner", - ], - ) - self.assertEqual( - run.call_args_list[3].args[0], - ["tmux", "set-option", "-p", "-t", "parent:2.4", "remain-on-exit", "on"], - ) - self.assertEqual( - run.call_args_list[5].args[0], - ["tmux", "set-option", "-p", "-t", "parent:2.4", "@agent-workflow-column", "1"], - ) - - def test_agent_columns_are_created_horizontally_before_vertical_splits(self): - listing = Mock(returncode=0, stdout="%1\torchestrator\t0\t0\t0\t\n%2\tagent\t80\t0\t0\t1\n") - created = Mock(returncode=0, stdout="parent:2.5\n") - ok = Mock(returncode=0, stdout="") - with patch("agent_workflow.tmux.run", side_effect=[listing, created, ok, ok, ok, ok, ok]) as run: - split_window("parent:2", "/tmp/work", "/tmp/runner") - command = run.call_args_list[1].args[0] - self.assertEqual(command[2], "-h") - self.assertEqual(command[command.index("-t") + 1], "%2") - - def test_vertical_splits_balance_across_full_width(self): - listing = Mock( - returncode=0, - stdout=( - "%1\torchestrator\t0\t0\t0\t\n" - "%2\tagent\t60\t0\t0\t1\n" - "%4\tagent\t60\t20\t0\t1\n" - "%3\tagent\t100\t0\t0\t2\n" - ), - ) - created = Mock(returncode=0, stdout="parent:2.6\n") - ok = Mock(returncode=0, stdout="") - with patch("agent_workflow.tmux.run", side_effect=[listing, created, ok, ok, ok, ok, ok]) as run: - split_window("parent:2", "/tmp/work", "/tmp/runner") - command = run.call_args_list[1].args[0] - self.assertEqual(command[2], "-v") - self.assertEqual(command[command.index("-t") + 1], "%3") - - def test_unlimited_shared_noninteractive_launch_stacks_vertically(self): - listing = Mock( - returncode=0, - stdout=( - "%1\torchestrator\t0\t0\t0\t\n" - "%2\tagent\t80\t0\t0\t1\n" - ), - ) - created = Mock(returncode=0, stdout="parent:2.5\n") - ok = Mock(returncode=0, stdout="") - with patch("agent_workflow.tmux.run", side_effect=[listing, created, ok, ok, ok, ok, ok]) as run: - split_window( - "parent:2", "/tmp/work", "/tmp/runner", - max_interactive_agent_panes=None, - max_interactive_agent_width=None, - max_interactive_agent_vertical=None, - ) - command = run.call_args_list[1].args[0] - self.assertEqual(command[2], "-v") - self.assertEqual(command[command.index("-t") + 1], "%2") - - def test_unmarked_live_right_pane_is_adopted_and_dead_panes_are_ignored(self): - listing = Mock( - returncode=0, - stdout=( - "%1\t\t0\t0\t0\t\n" - "%2\tagent\t60\t0\t1\t1\n" - "%4\t\t105\t0\t0\t\n" - ), - ) - created = Mock(returncode=0, stdout="parent:2.5\n") - ok = Mock(returncode=0, stdout="") - with patch.dict("agent_workflow.tmux.os.environ", {"TMUX_PANE": "%1"}, clear=True), patch( - "agent_workflow.tmux.run", side_effect=[listing, created, ok, ok, ok, ok, ok] - ) as run: - split_window("parent:2", "/tmp/work", "/tmp/runner") - command = run.call_args_list[1].args[0] - self.assertEqual(command[2], "-h") - self.assertEqual(command[command.index("-t") + 1], "%4") - - def test_interactive_agent_pane_limit_is_enforced_before_split(self): - listing = Mock( - returncode=0, - stdout=( - "%1\torchestrator\t0\t0\t0\t\n" - "%2\tagent\t60\t0\t0\t1\n" - "%3\tagent\t60\t10\t0\t1\n" - "%4\tagent\t60\t20\t0\t1\n" - "%5\tagent\t100\t0\t0\t2\n" - "%6\tagent\t100\t10\t0\t2\n" - "%7\tagent\t100\t20\t0\t2\n" - ), - ) - with patch.dict("agent_workflow.tmux.os.environ", {"TMUX_PANE": "%1"}, clear=True), patch( - "agent_workflow.tmux.run", return_value=listing - ) as run, self.assertRaisesRegex(WorkflowError, "pane limit reached: 6/6"): - split_window("parent:2", "/tmp/work", "/tmp/runner") - self.assertEqual(run.call_count, 1) - - def test_capacity_preflight_counts_live_non_orchestrator_panes(self): - listing = Mock( - returncode=0, - stdout=( - "%1\torchestrator\t0\n" - "%2\tagent\t0\n" - "%3\tagent\t0\n" - "%4\tagent\t0\n" - "%5\tagent\t1\n" - ), - ) - with patch("agent_workflow.tmux.run", return_value=listing), self.assertRaisesRegex( - WorkflowError, "pane limit reached: 3/3" - ): - ensure_interactive_capacity("parent:2", 3) diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index dc9a451..0000000 --- a/tests/test_util.py +++ /dev/null @@ -1,20 +0,0 @@ -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.errors import WorkflowError -from agent_workflow.util import atomic_write_json, read_json, slug, validate_id - - -class UtilTests(unittest.TestCase): - def test_safe_identifiers_and_slug(self): - self.assertEqual(validate_id("project-p0-01"), "project-p0-01") - self.assertEqual(slug("P0-01 Proxy Contract"), "p0-01-proxy-contract") - with self.assertRaises(WorkflowError): - validate_id("../unsafe") - - def test_atomic_json_round_trip(self): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "state.json" - atomic_write_json(path, {"status": "running"}) - self.assertEqual(read_json(path)["status"], "running") diff --git a/tests/test_workflow.py b/tests/test_workflow.py deleted file mode 100644 index f59a9ae..0000000 --- a/tests/test_workflow.py +++ /dev/null @@ -1,1702 +0,0 @@ -from __future__ import annotations - -import json -import os -import shutil -import tempfile -import threading -import time -import unittest -from dataclasses import replace -from pathlib import Path -from unittest.mock import patch - -from agent_workflow.contracts import load_schema -from agent_workflow.errors import WorkflowError -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json -from agent_workflow.workflow import ( - WORKFLOW_EVENT_SCHEMA, - WORKFLOW_NODE_BINDING_SCHEMA, - WORKFLOW_NODE_RESULT_SCHEMA, - WORKFLOW_RUN_SCHEMA, - WORKFLOW_SNAPSHOT_SCHEMA, - WORKFLOW_STATUS_SCHEMA, - append_workflow_event, - build_workflow_run, - initial_status, - normalize_snapshot, - record_workflow_binding, - record_workflow_transition, - reconstruct_workflow_status, - snapshot_sha256, - workflow_events_path, - workflow_lock, - workflow_run_path, - workflow_snapshot_path, - workflow_status_path, - write_workflow_projection, -) -from agent_workflow.scheduler import SchedulerService, calculate_eligibility, plan_launches -from agent_workflow.config import defaults -from agent_workflow.workflow_service import WorkflowService -from run_fixtures import write_run_contracts - - -class WorkflowContractTests(unittest.TestCase): - def test_workflow_schemas_are_discoverable(self) -> None: - for schema_id in ( - WORKFLOW_SNAPSHOT_SCHEMA, - WORKFLOW_NODE_BINDING_SCHEMA, - WORKFLOW_NODE_RESULT_SCHEMA, - WORKFLOW_EVENT_SCHEMA, - WORKFLOW_STATUS_SCHEMA, - WORKFLOW_RUN_SCHEMA, - ): - self.assertEqual(load_schema(schema_id)["$id"], schema_id) - - -class WorkflowServiceErrorMappingTests(unittest.TestCase): - def test_start_wraps_filesystem_errors(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - snapshot = root / "snapshot.json" - snapshot.write_text(json.dumps(self._snapshot_data()), encoding="utf-8") - service = WorkflowService( - scheduler=SchedulerService( - settings=defaults(root / "config.toml"), - run_dir=root / "run", - workdir=root, - launch_fn=lambda *_: {"run_id": "a"}, - ) - ) - with patch("agent_workflow.workflow_service.atomic_write_json", side_effect=OSError("boom")): - with self.assertRaisesRegex(WorkflowError, "workflow start failed"): - service.start(snapshot) - - def test_resume_wraps_filesystem_errors(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - snapshot = root / "snapshot.json" - snapshot.write_text(json.dumps(self._snapshot_data()), encoding="utf-8") - run_dir = root / "run" - run_dir.mkdir() - (run_dir / "workflow-run.json").write_text("{}", encoding="utf-8") - stored_snapshot = run_dir / "workflow-snapshot.json" - stored_snapshot.write_text( - snapshot.read_text(encoding="utf-8"), encoding="utf-8" - ) - stored_snapshot.chmod(0o444) - settings = replace( - defaults(root / "config.toml"), state_root=root / "state" - ) - - def launch(_node, run_id): - write_run_contracts( - settings.state_root / "runs" / run_id, session_id=run_id - ) - return {"run_id": run_id} - - service = WorkflowService( - scheduler=SchedulerService( - settings=settings, - run_dir=run_dir, - workdir=root, - launch_fn=launch, - ) - ) - with patch("agent_workflow.workflow.atomic_write_json", side_effect=OSError("boom")): - with self.assertRaisesRegex(WorkflowError, "workflow resume failed"): - service.resume(snapshot) - - @staticmethod - def _snapshot_data() -> dict[str, object]: - return { - "schema": WORKFLOW_SNAPSHOT_SCHEMA, - "workflow_id": "wf-service", - "pack_id": "pack", - "pack_manifest_sha256": "a" * 64, - "nodes": [ - { - "node_id": "A", - "session_id": "a", - "prompt_path": "a.md", - "dependencies": [], - } - ], - } - - -class SchedulerServiceTests(unittest.TestCase): - def _snapshot(self) -> dict[str, object]: - return { - "schema": WORKFLOW_SNAPSHOT_SCHEMA, - "workflow_id": "wf-scheduler", - "pack_id": "pack", - "pack_manifest_sha256": "a" * 64, - "nodes": [ - {"node_id": "A", "session_id": "a", "prompt_path": "a.md", "dependencies": []}, - {"node_id": "B", "session_id": "b", "prompt_path": "b.md", "dependencies": []}, - {"node_id": "C", "session_id": "c", "prompt_path": "c.md", "dependencies": ["A"]}, - ], - } - - def test_eligibility_is_bounded_and_failed_prerequisite_blocks_dependent(self): - snapshot = normalize_snapshot(self._snapshot()) - status = initial_status(snapshot) - self.assertEqual(calculate_eligibility(snapshot, status), ["A", "B"]) - self.assertEqual(plan_launches(snapshot, status, max_parallelism=1), ["A"]) - status["nodes"][0]["state"] = "failed" - self.assertEqual(calculate_eligibility(snapshot, status), ["B"]) - - def test_running_nodes_consume_parallelism_capacity(self): - snapshot = normalize_snapshot(self._snapshot()) - status = initial_status(snapshot) - by_id = {item["node_id"]: item for item in status["nodes"]} - by_id["A"].update(state="running", run_id="a", attempt=1) - self.assertEqual(plan_launches(snapshot, status, max_parallelism=1), []) - self.assertEqual(plan_launches(snapshot, status, max_parallelism=2), ["B"]) - - def test_launch_is_idempotent_on_replay_and_parallelism_is_bounded(self): - snapshot = normalize_snapshot(self._snapshot()) - snapshot["nodes"] = [snapshot["nodes"][0]] - snapshot = normalize_snapshot(snapshot) - active = 0 - maximum = 0 - calls: list[str] = [] - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(node, run_id): - nonlocal active, maximum - active += 1 - maximum = max(maximum, active) - calls.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - active -= 1 - return {"run_id": run_id} - - service = SchedulerService( - settings=settings, - run_dir=Path(tmp) / "workflow", - workdir=Path(tmp), - max_parallelism=1, - launch_fn=launch, - ) - first = service.launch_eligible(snapshot) - second = service.launch_eligible(snapshot) - self.assertEqual(len(first["plans"]), 1) - self.assertEqual(second["plans"], []) - self.assertEqual(calls, ["a"]) - self.assertEqual(maximum, 1) - - def test_recoverable_node_can_be_retried_and_replayed(self): - snapshot = normalize_snapshot({**self._snapshot(), "nodes": [self._snapshot()["nodes"][0]]}) - calls: list[str] = [] - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - run_dir = root / "workflow" - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - digest = snapshot_sha256(snapshot) - record_workflow_binding( - run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", - attempt=1, actor="test", reason="bound", snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="running", snapshot_sha256=digest, - previous_state="eligible", next_state="running", - ) - record_workflow_transition( - run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="manual recovery required", snapshot_sha256=digest, - previous_state="running", next_state="recoverable", - ) - - def launch(node, run_id): - calls.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = SchedulerService( - settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch, - ) - result = service.retry(snapshot, "A") - self.assertEqual(result["plan"]["attempt"], 2) - self.assertEqual(result["plan"]["retry_of_run_id"], "a") - self.assertEqual(calls, ["a-retry-2"]) - replayed = service.status(snapshot) - self.assertEqual(replayed["nodes"][0]["state"], "running") - self.assertEqual(replayed["nodes"][0]["attempt"], 2) - - def test_failed_launch_can_be_retried_with_lineage(self): - snapshot = normalize_snapshot(self._snapshot()) - calls: list[str] = [] - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(node, run_id): - calls.append(run_id) - if len(calls) == 1: - raise RuntimeError("synthetic launch failure") - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = SchedulerService( - settings=settings, - run_dir=root / "workflow", - workdir=root, - launch_fn=launch, - ) - with self.assertRaises(RuntimeError): - service.launch_eligible(snapshot) - result = service.retry(snapshot, "A") - self.assertEqual(result["plan"]["attempt"], 2) - self.assertEqual(result["plan"]["retry_of_run_id"], "a") - self.assertEqual(calls, ["a", "a-retry-2"]) - - def test_two_services_serialize_binding_and_external_launch(self): - snapshot = normalize_snapshot(self._snapshot()) - calls: list[str] = [] - calls_lock = threading.Lock() - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - run_dir = root / "workflow" - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(node, run_id): - with calls_lock: - calls.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - time.sleep(0.05) - return {"run_id": run_id} - - services = [ - SchedulerService(settings=settings, run_dir=run_dir, - workdir=root, launch_fn=launch) - for i in range(2) - ] - threads = [threading.Thread(target=service.launch_eligible, args=(snapshot,)) for service in services] - for thread in threads: - thread.start() - for thread in threads: - thread.join() - self.assertEqual(calls, ["a"]) - self.assertEqual(services[0].status(snapshot)["nodes"][0]["state"], "running") - - def test_running_event_cannot_circularly_prove_deleted_child_exists(self): - snapshot = normalize_snapshot({**self._snapshot(), "nodes": [self._snapshot()["nodes"][0]]}) - calls: list[str] = [] - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - run_dir = root / "workflow" - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - digest = snapshot_sha256(snapshot) - record_workflow_binding( - run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", - attempt=1, actor="test", reason="bound", snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="authoritative child once existed", snapshot_sha256=digest, - previous_state="eligible", next_state="running", - details={"child_run_id": "a"}, - ) - service = SchedulerService( - settings=settings, run_dir=run_dir, workdir=root, - launch_fn=lambda node, run_id: calls.append(run_id), - ) - service.launch_eligible(snapshot) - self.assertEqual(service.status(snapshot)["nodes"][0]["state"], "recoverable") - self.assertEqual(calls, []) - - def test_resume_reconciles_sealed_child_and_launches_dependent(self): - snapshot = normalize_snapshot( - {**self._snapshot(), "nodes": [self._snapshot()["nodes"][0], self._snapshot()["nodes"][2]]} - ) - launched: list[str] = [] - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - child = settings.state_root / "runs" / "a" - write_run_contracts(child, session_id="a") - completion = json.loads((child / "completion.json").read_text(encoding="utf-8")) - completion.update(result="completed", head_revision="abc123", unresolved=[]) - atomic_write_json(child / "completion.json", completion) - final_status = json.loads((child / "final-status.json").read_text(encoding="utf-8")) - final_status.update(status="completed") - atomic_write_json(child / "final-status.json", final_status) - seal_run(child, session_id="a") - - run_dir = root / "workflow" - digest = snapshot_sha256(snapshot) - record_workflow_binding( - run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", - attempt=1, actor="test", reason="bound", snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="running", snapshot_sha256=digest, - previous_state="eligible", next_state="running", - details={"child_run_id": "a"}, - ) - - def launch(node, run_id): - launched.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = SchedulerService( - settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch, - ) - service.launch_eligible(snapshot) - status = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} - self.assertEqual(status["A"]["state"], "completed") - self.assertEqual(status["C"]["state"], "running") - self.assertEqual(launched, ["c"]) - - def test_mapping_only_launch_result_is_not_authoritative(self): - snapshot = normalize_snapshot( - {**self._snapshot(), "nodes": [self._snapshot()["nodes"][0]]} - ) - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - service = SchedulerService( - settings=settings, - run_dir=root / "workflow", - workdir=root, - launch_fn=lambda node, run_id: {"run_id": run_id}, - ) - with self.assertRaisesRegex(WorkflowError, "no authoritative child run"): - service.launch_eligible(snapshot) - self.assertEqual(service.status(snapshot)["nodes"][0]["state"], "recoverable") - - def test_retry_reopens_dependency_failed_descendants(self): - snapshot = normalize_snapshot( - {**self._snapshot(), "nodes": [self._snapshot()["nodes"][0], self._snapshot()["nodes"][2]]} - ) - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - run_dir = root / "workflow" - digest = snapshot_sha256(snapshot) - record_workflow_binding( - run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", - attempt=1, actor="test", reason="bound", snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="running", snapshot_sha256=digest, - previous_state="eligible", next_state="running", - ) - record_workflow_transition( - run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="failed", snapshot_sha256=digest, - previous_state="running", next_state="failed", - ) - launched: list[str] = [] - - def launch(node, run_id): - launched.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = SchedulerService( - settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch, - ) - service.launch_eligible(snapshot) - by_id = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} - self.assertEqual(by_id["C"]["state"], "failed") - - service.retry(snapshot, "A") - service.launch_eligible(snapshot) - by_id = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} - self.assertEqual(by_id["A"]["state"], "running") - self.assertEqual(by_id["C"]["state"], "blocked") - - retry_run = settings.state_root / "runs" / "a-retry-2" - completion = json.loads((retry_run / "completion.json").read_text(encoding="utf-8")) - completion.update(result="completed", head_revision="abc123", unresolved=[]) - atomic_write_json(retry_run / "completion.json", completion) - final_status = json.loads((retry_run / "final-status.json").read_text(encoding="utf-8")) - final_status.update(status="completed") - atomic_write_json(retry_run / "final-status.json", final_status) - seal_run(retry_run, session_id="a-retry-2") - - service.launch_eligible(snapshot) - by_id = {item["node_id"]: item for item in service.status(snapshot)["nodes"]} - self.assertEqual(by_id["A"]["state"], "completed") - self.assertEqual(by_id["C"]["state"], "running") - self.assertEqual(launched, ["a-retry-2", "c"]) - - def test_restart_windows_reuse_binding_and_fail_closed_running_without_child(self): - snapshot = normalize_snapshot(self._snapshot()) - calls: list[str] = [] - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) / "workflow" - settings = replace(defaults(Path(tmp) / "config.toml"), state_root=Path(tmp) / "state") - digest = snapshot_sha256(snapshot) - record_workflow_binding(run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", - attempt=1, actor="scheduler", reason="crash after binding", snapshot_sha256=digest) - - def launch(node, run_id): - calls.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = SchedulerService(settings=settings, run_dir=run_dir, workdir=Path(tmp), launch_fn=launch) - service.launch_eligible(snapshot) - self.assertEqual(calls, ["a"]) - record_workflow_transition(run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="inject crash after running", snapshot_sha256=digest, - previous_state="running", next_state="recoverable") - self.assertEqual(service.status(snapshot)["nodes"][0]["state"], "recoverable") - - shutil.rmtree(settings.state_root / "runs" / "a") - orphan_dir = Path(tmp) / "orphan-workflow" - orphan_snapshot = normalize_snapshot({**snapshot, "nodes": [snapshot["nodes"][0]]}) - orphan_digest = snapshot_sha256(orphan_snapshot) - record_workflow_binding(orphan_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", - attempt=1, actor="test", reason="seed binding", snapshot_sha256=orphan_digest) - record_workflow_transition(orphan_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="seed running crash window", snapshot_sha256=orphan_digest, - previous_state="eligible", next_state="running") - orphan_service = SchedulerService(settings=settings, run_dir=orphan_dir, workdir=Path(tmp), launch_fn=launch) - orphan_service.launch_eligible(orphan_snapshot) - self.assertEqual(orphan_service.status(orphan_snapshot)["nodes"][0]["state"], "recoverable") - self.assertEqual(calls, ["a"]) - - def test_completed_prerequisite_is_journaled_before_dependent_launch_and_provenance_is_forwarded(self): - snapshot = normalize_snapshot(self._snapshot()) - snapshot["nodes"] = [snapshot["nodes"][0], snapshot["nodes"][2]] - snapshot = normalize_snapshot(snapshot) - run_dir = None - seen: list[dict[str, object]] = [] - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) / "workflow" - digest = snapshot_sha256(snapshot) - record_workflow_binding(run_dir, workflow_id="wf-scheduler", node_id="A", run_id="a", attempt=1, - actor="test", reason="A bound", snapshot_sha256=digest) - record_workflow_transition(run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="A running", snapshot_sha256=digest, previous_state="eligible", next_state="running") - record_workflow_transition(run_dir, workflow_id="wf-scheduler", node_id="A", actor="test", - reason="A complete", snapshot_sha256=digest, previous_state="running", next_state="completed") - - settings = replace(defaults(Path(tmp) / "config.toml"), state_root=Path(tmp) / "state") - - def launch(node, run_id): - seen.append(dict(node)) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = SchedulerService(settings=settings, run_dir=run_dir, - workdir=Path(tmp), launch_fn=launch) - service.launch_eligible(snapshot) - self.assertEqual(seen[0]["node_id"], "C") - self.assertEqual(seen[0]["pack_id"], "pack") - self.assertIsNone(seen[0]["retry_of_run_id"]) - events = [json.loads(line) for line in workflow_events_path(run_dir).read_text().splitlines()] - self.assertTrue(any(event.get("previous_state") == "blocked" and event.get("next_state") == "eligible" for event in events)) - - -class WorkflowReplayTests(unittest.TestCase): - @staticmethod - def _snapshot() -> dict[str, object]: - return { - "schema": WORKFLOW_SNAPSHOT_SCHEMA, - "workflow_id": "wf-contract", - "pack_id": "workflow-foundations-next", - "pack_manifest_sha256": "0" * 64, - "nodes": [ - { - "node_id": "A", - "ticket_id": "WF-00", - "session_id": "wf-00", - "tier": "B", - "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", - "dependencies": [], - }, - { - "node_id": "B", - "ticket_id": "WF-01", - "session_id": "wf-01", - "tier": "A", - "prompt_path": "phase-0/tickets/WF-01-scheduler-service.md", - "dependencies": ["A"], - }, - ], - } - - @staticmethod - def _write_events(path: Path, events: list[dict[str, object]]) -> None: - path.write_text( - "".join(json.dumps(event, sort_keys=True) + "\n" for event in events), - encoding="utf-8", - ) - - def test_snapshot_rejects_unknown_and_cyclic_dependencies(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - unknown = self._snapshot() - unknown["nodes"] = [ - unknown["nodes"][0], - { - "node_id": "C", - "session_id": "wf-02", - "prompt_path": "phase-0/tickets/WF-02-restart-and-cli.md", - "dependencies": ["missing"], - }, - ] - with self.assertRaisesRegex(WorkflowError, "unknown dependency"): - normalize_snapshot(unknown) - - cyclic = self._snapshot() - cyclic["nodes"] = [ - { - "node_id": "A", - "session_id": "wf-00", - "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", - "dependencies": ["B"], - }, - { - "node_id": "B", - "session_id": "wf-01", - "prompt_path": "phase-0/tickets/WF-01-scheduler-service.md", - "dependencies": ["A"], - }, - ] - with self.assertRaisesRegex(WorkflowError, "dependency cycle"): - normalize_snapshot(cyclic) - - def test_snapshot_rejects_duplicate_dependencies_and_session_ids(self) -> None: - duplicate_dependency = self._snapshot() - duplicate_dependency["nodes"][1]["dependencies"] = ["A", "A"] - with self.assertRaisesRegex(WorkflowError, "duplicate dependency"): - normalize_snapshot(duplicate_dependency) - - duplicate_session = self._snapshot() - duplicate_session["nodes"][1]["session_id"] = "wf-00" - with self.assertRaisesRegex(WorkflowError, "duplicate workflow session ID"): - normalize_snapshot(duplicate_session) - - def test_event_journal_rejects_symlink_and_corrupt_existing_content(self) -> None: - snapshot = normalize_snapshot(self._snapshot()) - digest = snapshot_sha256(snapshot) - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) / "symlink" - run_dir.mkdir() - target = Path(tmp) / "target.jsonl" - target.touch() - os.symlink(target, workflow_events_path(run_dir)) - with self.assertRaises(WorkflowError): - record_workflow_binding( - run_dir, workflow_id="wf-contract", node_id="A", run_id="wf-00", - attempt=1, actor="test", reason="bound", snapshot_sha256=digest, - ) - - lock_dir = Path(tmp) / "lock-symlink" - lock_dir.mkdir() - os.symlink(target, lock_dir / "workflow.lock") - with self.assertRaises(WorkflowError): - with workflow_lock(lock_dir): - self.fail("symlink workflow lock should not be acquired") - - corrupt_dir = Path(tmp) / "corrupt" - corrupt_dir.mkdir() - workflow_events_path(corrupt_dir).write_text("{}\n", encoding="utf-8") - with self.assertRaises(WorkflowError): - record_workflow_binding( - corrupt_dir, workflow_id="wf-contract", node_id="A", run_id="wf-00", - attempt=1, actor="test", reason="bound", snapshot_sha256=digest, - ) - self.assertEqual(workflow_events_path(corrupt_dir).read_text(encoding="utf-8"), "{}\n") - - def test_replay_reconstructs_status_from_snapshot_and_events(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - snapshot = normalize_snapshot(self._snapshot()) - snapshot_path = workflow_snapshot_path(run_dir) - events_path = workflow_events_path(run_dir) - status_path = workflow_status_path(run_dir) - run_path = workflow_run_path(run_dir) - snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") - digest = snapshot_sha256(snapshot) - - record_workflow_binding( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="A", - run_id="wf-00-run", - attempt=1, - actor="scheduler", - reason="launch A", - snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="A", - actor="scheduler", - reason="A running", - snapshot_sha256=digest, - previous_state="eligible", - next_state="running", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="A", - actor="scheduler", - reason="A complete", - snapshot_sha256=digest, - previous_state="running", - next_state="completed", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="B", - actor="scheduler", - reason="dependency cleared", - snapshot_sha256=digest, - previous_state="blocked", - next_state="eligible", - ) - record_workflow_binding( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="B", - run_id="wf-01-run", - attempt=1, - actor="scheduler", - reason="launch B", - snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="B", - actor="scheduler", - reason="B running", - snapshot_sha256=digest, - previous_state="eligible", - next_state="running", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="B", - actor="scheduler", - reason="B failed", - snapshot_sha256=digest, - previous_state="running", - next_state="failed", - ) - - status = reconstruct_workflow_status(snapshot, events_path) - self.assertEqual(status["workflow_state"], "failed") - self.assertEqual(status["event_count"], 7) - self.assertEqual([item["state"] for item in status["nodes"]], ["completed", "failed"]) - self.assertEqual(status["nodes"][0]["run_id"], "wf-00-run") - self.assertEqual(status["nodes"][1]["run_id"], "wf-01-run") - self.assertEqual(status["nodes"][1]["terminal_reason"], "B failed") - run = build_workflow_run( - snapshot=snapshot, - snapshot_path=snapshot_path, - events_path=events_path, - status_path=status_path, - ) - self.assertEqual(run["status"]["workflow_state"], "failed") - self.assertEqual(run["snapshot_sha256"], digest) - - write_workflow_projection( - snapshot=snapshot, - snapshot_path=snapshot_path, - events_path=events_path, - status_path=status_path, - run_path=run_path, - ) - self.assertTrue(status_path.is_file()) - self.assertTrue(run_path.is_file()) - - def test_replay_allows_failed_node_retry_sequence(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - snapshot = normalize_snapshot( - { - "schema": WORKFLOW_SNAPSHOT_SCHEMA, - "workflow_id": "wf-retry", - "pack_id": "workflow-foundations-next", - "pack_manifest_sha256": "2" * 64, - "nodes": [ - { - "node_id": "A", - "session_id": "wf-00", - "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", - "dependencies": [], - } - ], - } - ) - digest = snapshot_sha256(snapshot) - events_path = workflow_events_path(run_dir) - self._write_events( - events_path, - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "launch A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:01:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 3, - "timestamp": "2026-01-01T00:02:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A failed", - "snapshot_sha256": digest, - "previous_state": "running", - "next_state": "failed", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 4, - "timestamp": "2026-01-01T00:03:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "retry A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run-retry", - "attempt": 2, - "retry_of_run_id": "wf-00-run", - "bound_at": "2026-01-01T00:03:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 5, - "timestamp": "2026-01-01T00:04:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "retry released", - "snapshot_sha256": digest, - "previous_state": "failed", - "next_state": "eligible", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 6, - "timestamp": "2026-01-01T00:05:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "retry running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 7, - "timestamp": "2026-01-01T00:06:00+00:00", - "workflow_id": "wf-retry", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "retry complete", - "snapshot_sha256": digest, - "previous_state": "running", - "next_state": "completed", - "binding": None, - "details": None, - }, - ], - ) - status = reconstruct_workflow_status(snapshot, events_path) - self.assertEqual(status["workflow_state"], "completed") - self.assertEqual(status["nodes"][0]["state"], "completed") - self.assertEqual(status["nodes"][0]["run_id"], "wf-00-run-retry") - self.assertEqual(status["nodes"][0]["attempt"], 2) - self.assertEqual(status["nodes"][0]["retry_of_run_id"], "wf-00-run") - self.assertEqual(status["nodes"][0]["terminal_reason"], "retry complete") - - def test_replay_reports_completed_for_terminal_workflow(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - snapshot = normalize_snapshot( - { - "schema": WORKFLOW_SNAPSHOT_SCHEMA, - "workflow_id": "wf-done", - "pack_id": "workflow-foundations-next", - "pack_manifest_sha256": "1" * 64, - "nodes": [ - { - "node_id": "A", - "session_id": "wf-00", - "prompt_path": "phase-0/tickets/WF-00-contract-and-state.md", - "dependencies": [], - } - ], - } - ) - digest = snapshot_sha256(snapshot) - events_path = workflow_events_path(run_dir) - record_workflow_binding( - run_dir, - workflow_id="wf-done", - node_id="A", - run_id="wf-00-run", - attempt=1, - actor="scheduler", - reason="launch A", - snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, - workflow_id="wf-done", - node_id="A", - actor="scheduler", - reason="running", - snapshot_sha256=digest, - previous_state="eligible", - next_state="running", - ) - record_workflow_transition( - run_dir, - workflow_id="wf-done", - node_id="A", - actor="scheduler", - reason="finished", - snapshot_sha256=digest, - previous_state="running", - next_state="completed", - ) - status = reconstruct_workflow_status(snapshot, events_path) - self.assertEqual(status["workflow_state"], "completed") - self.assertEqual(status["nodes"][0]["state"], "completed") - - def test_replay_rejects_unbound_execution_and_dependency_release(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - snapshot = normalize_snapshot(self._snapshot()) - digest = snapshot_sha256(snapshot) - events_path = workflow_events_path(run_dir) - - self._write_events( - events_path, - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "unbound run", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - } - ], - ) - with self.assertRaisesRegex(WorkflowError, "without a current binding"): - reconstruct_workflow_status(snapshot, events_path) - - self._write_events( - events_path, - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "B", - "actor": "scheduler", - "reason": "too early", - "snapshot_sha256": digest, - "previous_state": "blocked", - "next_state": "eligible", - "binding": None, - "details": None, - } - ], - ) - with self.assertRaisesRegex(WorkflowError, "dependencies complete"): - reconstruct_workflow_status(snapshot, events_path) - - def test_replay_rejects_invalid_binding_lineage(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - snapshot = normalize_snapshot(self._snapshot()) - digest = snapshot_sha256(snapshot) - events_path = workflow_events_path(run_dir) - - cases = [ - ( - "must be current", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "not current", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": False, - }, - "details": None, - } - ], - ), - ( - "first binding must use attempt 1", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "bad attempt", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 2, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - } - ], - ), - ( - "first binding cannot have retry lineage", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "bad retry lineage", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": "wf-previous-run", - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - } - ], - ), - ( - "retry attempt must be the next attempt", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "launch A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:01:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 3, - "timestamp": "2026-01-01T00:02:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A failed", - "snapshot_sha256": digest, - "previous_state": "running", - "next_state": "failed", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 4, - "timestamp": "2026-01-01T00:03:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "skip attempt", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run-retry", - "attempt": 3, - "retry_of_run_id": "wf-00-run", - "bound_at": "2026-01-01T00:03:00+00:00", - "current": True, - }, - "details": None, - }, - ], - ), - ( - "retry must reference the current run", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "launch A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:01:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 3, - "timestamp": "2026-01-01T00:02:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A failed", - "snapshot_sha256": digest, - "previous_state": "running", - "next_state": "failed", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 4, - "timestamp": "2026-01-01T00:03:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "wrong parent", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run-retry", - "attempt": 2, - "retry_of_run_id": "wf-wrong-parent", - "bound_at": "2026-01-01T00:03:00+00:00", - "current": True, - }, - "details": None, - }, - ], - ), - ( - "run ID reused", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "launch A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:01:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 3, - "timestamp": "2026-01-01T00:02:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A failed", - "snapshot_sha256": digest, - "previous_state": "running", - "next_state": "failed", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 4, - "timestamp": "2026-01-01T00:03:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "reused", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 2, - "retry_of_run_id": "wf-00-run", - "bound_at": "2026-01-01T00:03:00+00:00", - "current": True, - }, - "details": None, - }, - ], - ), - ( - "cannot be bound while blocked", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "B", - "actor": "scheduler", - "reason": "too early", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-01-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - } - ], - ), - ( - "cannot be bound while running", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "launch A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:01:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 3, - "timestamp": "2026-01-01T00:02:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "double bind", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run-2", - "attempt": 2, - "retry_of_run_id": "wf-00-run", - "bound_at": "2026-01-01T00:02:00+00:00", - "current": True, - }, - "details": None, - }, - ], - ), - ( - "cannot be bound while completed", - [ - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 1, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "launch A", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run", - "attempt": 1, - "retry_of_run_id": None, - "bound_at": "2026-01-01T00:00:00+00:00", - "current": True, - }, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:01:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A running", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 3, - "timestamp": "2026-01-01T00:02:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "A complete", - "snapshot_sha256": digest, - "previous_state": "running", - "next_state": "completed", - "binding": None, - "details": None, - }, - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 4, - "timestamp": "2026-01-01T00:03:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-bound", - "node_id": "A", - "actor": "scheduler", - "reason": "late bind", - "snapshot_sha256": digest, - "previous_state": None, - "next_state": None, - "binding": { - "run_id": "wf-00-run-2", - "attempt": 2, - "retry_of_run_id": "wf-00-run", - "bound_at": "2026-01-01T00:03:00+00:00", - "current": True, - }, - "details": None, - }, - ], - ), - ] - for expected, events in cases: - with self.subTest(expected=expected): - self._write_events(events_path, events) - with self.assertRaisesRegex(WorkflowError, expected): - reconstruct_workflow_status(snapshot, events_path) - - def test_replay_rejects_corruption_and_invalid_transitions(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - run_dir = Path(tmp) - snapshot = normalize_snapshot(self._snapshot()) - digest = snapshot_sha256(snapshot) - events_path = workflow_events_path(run_dir) - record_workflow_transition( - run_dir, - workflow_id="wf-contract", - node_id="A", - actor="scheduler", - reason="bad transition", - snapshot_sha256=digest, - previous_state="blocked", - next_state="completed", - ) - with self.assertRaisesRegex(WorkflowError, "invalid workflow transition"): - reconstruct_workflow_status(snapshot, events_path) - - events_path.write_text("not json\n", encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "invalid workflow event JSON"): - reconstruct_workflow_status(snapshot, events_path) - - events_path.write_text( - json.dumps( - { - "schema": WORKFLOW_EVENT_SCHEMA, - "sequence": 2, - "timestamp": "2026-01-01T00:00:00+00:00", - "workflow_id": "wf-contract", - "kind": "node-transition", - "node_id": "A", - "actor": "scheduler", - "reason": "bad sequence", - "snapshot_sha256": digest, - "previous_state": "eligible", - "next_state": "running", - "binding": None, - "details": None, - } - ) - + "\n", - encoding="utf-8", - ) - with self.assertRaisesRegex(WorkflowError, "sequence mismatch"): - reconstruct_workflow_status(snapshot, events_path) - - -class WorkflowServiceTests(unittest.TestCase): - def _snapshot(self, *, terminal: bool = False) -> dict[str, object]: - nodes = [ - {"node_id": "A", "session_id": "wf-a", "prompt_path": "a.md", "dependencies": []}, - {"node_id": "B", "session_id": "wf-b", "prompt_path": "b.md", "dependencies": ["A"]}, - ] - snapshot = { - "schema": WORKFLOW_SNAPSHOT_SCHEMA, - "workflow_id": "wf-cli", - "pack_id": "pack", - "pack_manifest_sha256": "1" * 64, - "nodes": [nodes[0]] if terminal else nodes, - } - return normalize_snapshot(snapshot) - - def test_validate_start_status_and_resume_share_result_contract(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - snapshot_path = root / "snapshot.json" - snapshot = self._snapshot() - snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") - run_dir = root / "workflow" - launched: list[str] = [] - - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(node, run_id): - launched.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = WorkflowService( - scheduler=SchedulerService( - settings=settings, - run_dir=run_dir, - workdir=root, - launch_fn=launch, - ) - ) - - validate = service.validate(snapshot_path) - self.assertEqual(validate["schema"], WORKFLOW_NODE_RESULT_SCHEMA) - self.assertEqual(validate["action"], "validate") - self.assertEqual(validate["result"]["node_count"], 2) - - start = service.start(snapshot_path) - self.assertEqual(start["started"], True) - self.assertEqual(start["scheduled"], ["A"]) - self.assertEqual(launched, ["wf-a"]) - self.assertTrue((run_dir / "workflow-run.json").is_file()) - projected_status = json.loads( - workflow_status_path(run_dir).read_text(encoding="utf-8") - ) - projected_run = json.loads( - workflow_run_path(run_dir).read_text(encoding="utf-8") - ) - self.assertEqual(projected_status["workflow_state"], "running") - self.assertEqual(projected_run["status"], projected_status) - - status = service.status(snapshot_path) - self.assertEqual(status["action"], "status") - self.assertEqual(status["result"]["workflow_state"], "running") - - changed = json.loads(snapshot_path.read_text(encoding="utf-8")) - changed["pack_manifest_sha256"] = "2" * 64 - snapshot_path.write_text(json.dumps(changed), encoding="utf-8") - with self.assertRaisesRegex(WorkflowError, "does not match"): - service.status(snapshot_path) - with self.assertRaisesRegex(WorkflowError, "does not match"): - service.resume(snapshot_path) - snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") - - resume = service.resume(snapshot_path) - self.assertEqual(resume["resumed"], True) - self.assertEqual(resume["scheduled"], []) - - def test_status_recovers_missing_mutable_projections_from_canonical_state(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - snapshot = self._snapshot(terminal=True) - snapshot_path = root / "snapshot.json" - snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(_node, run_id): - write_run_contracts( - settings.state_root / "runs" / run_id, session_id=run_id - ) - return {"run_id": run_id} - - run_dir = root / "workflow" - service = WorkflowService( - scheduler=SchedulerService( - settings=settings, run_dir=run_dir, workdir=root, launch_fn=launch - ) - ) - service.start(snapshot_path) - workflow_status_path(run_dir).unlink() - workflow_run_path(run_dir).unlink() - - status = service.status(snapshot_path) - - self.assertEqual(status["result"]["workflow_state"], "running") - self.assertTrue(workflow_status_path(run_dir).is_file()) - self.assertTrue(workflow_run_path(run_dir).is_file()) - - def test_started_snapshot_is_read_only_and_writable_tampering_fails_closed(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - snapshot = self._snapshot(terminal=True) - snapshot_path = root / "snapshot.json" - snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(node, run_id): - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = WorkflowService( - scheduler=SchedulerService( - settings=settings, run_dir=root / "workflow", workdir=root, launch_fn=launch, - ) - ) - service.start(snapshot_path) - stored = workflow_snapshot_path(root / "workflow") - self.assertEqual(stored.stat().st_mode & 0o222, 0) - stored.chmod(0o644) - with self.assertRaisesRegex(WorkflowError, "read-only"): - service.status(snapshot_path) - - def test_duplicate_start_invalid_root_and_terminal_workflow(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - snapshot_path = root / "snapshot.json" - snapshot = self._snapshot(terminal=True) - snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") - run_dir = root / "workflow" - launches: list[str] = [] - - settings = replace(defaults(root / "config.toml"), state_root=root / "state") - - def launch(node, run_id): - launches.append(run_id) - write_run_contracts(settings.state_root / "runs" / run_id, session_id=run_id) - return {"run_id": run_id} - - service = WorkflowService( - scheduler=SchedulerService( - settings=settings, - run_dir=run_dir, - workdir=root, - launch_fn=launch, - ) - ) - service.start(snapshot_path) - digest = snapshot_sha256(snapshot) - record_workflow_transition( - run_dir, - workflow_id="wf-cli", - node_id="A", - actor="test", - reason="terminal complete", - snapshot_sha256=digest, - previous_state="running", - next_state="completed", - ) - with self.assertRaisesRegex(WorkflowError, "already started"): - service.start(snapshot_path) - self.assertEqual(service.resume(snapshot_path)["scheduled"], []) - self.assertEqual(launches, ["wf-a"]) - missing = WorkflowService( - scheduler=SchedulerService( - settings=defaults(root / "config.toml"), - run_dir=root / "missing", - workdir=root, - ) - ) - with self.assertRaisesRegex(WorkflowError, "has not been started"): - missing.resume(snapshot_path) diff --git a/tests/test_workflow_receipt.py b/tests/test_workflow_receipt.py deleted file mode 100644 index 0aac990..0000000 --- a/tests/test_workflow_receipt.py +++ /dev/null @@ -1,378 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -import tempfile -import unittest -from dataclasses import replace -from pathlib import Path - -from agent_workflow.config import defaults -from agent_workflow.errors import WorkflowError -from agent_workflow.approval import lifecycle_disposition -from agent_workflow.lifecycle import record -from agent_workflow.receipts import seal_run -from agent_workflow.util import atomic_write_json, sha256_file -from agent_workflow.workflow import ( - normalize_snapshot, - record_workflow_binding, - record_workflow_transition, - snapshot_sha256, - workflow_events_path, - workflow_snapshot_path, -) -from agent_workflow.workflow_receipt import ( - build_workflow_receipt, - seal_workflow, - verify_workflow_receipt, - workflow_receipt_path, -) -from run_fixtures import write_run_contracts - - -def _sealed_child(settings, run_id: str) -> Path: - run = settings.state_root / "runs" / run_id - write_run_contracts(run, session_id=run_id) - completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) - completion.update(result="completed", head_revision="abc123", unresolved=[]) - atomic_write_json(run / "completion.json", completion) - final_status = json.loads((run / "final-status.json").read_text(encoding="utf-8")) - final_status.update(status="completed", disposition=None) - atomic_write_json(run / "final-status.json", final_status) - seal_run(run, session_id=run_id) - status = json.loads((run / "status.json").read_text(encoding="utf-8")) - status.update( - status="completed", - final_receipt_path=str(run / "final-receipt.json"), - final_receipt_sha256=sha256_file(run / "final-receipt.json"), - ) - atomic_write_json(run / "status.json", status) - return run - - -def _terminal_workflow(root: Path): - settings = replace(defaults(), state_root=root / "state") - run_dir = root / "workflow" - run_dir.mkdir() - child = _sealed_child(settings, "child-one") - snapshot = normalize_snapshot( - { - "workflow_id": "receipt-workflow", - "pack_id": "receipt-pack", - "pack_manifest_sha256": "a" * 64, - "nodes": [ - { - "node_id": "build", - "session_id": "child-one", - "prompt_path": "tickets/build.md", - "dependencies": [], - } - ], - } - ) - atomic_write_json(workflow_snapshot_path(run_dir), snapshot) - workflow_snapshot_path(run_dir).chmod(0o444) - workflow_events_path(run_dir).touch() - digest = snapshot_sha256(snapshot) - record_workflow_binding( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="build", - run_id="child-one", - attempt=1, - actor="scheduler", - reason="launch", - snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="build", - actor="scheduler", - reason="running", - snapshot_sha256=digest, - previous_state="eligible", - next_state="running", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="build", - actor="scheduler", - reason="completed", - snapshot_sha256=digest, - previous_state="running", - next_state="completed", - ) - return settings, run_dir, child - - -def _accepted_child(settings, run_id: str) -> tuple[Path, dict[str, object]]: - run = settings.state_root / "runs" / run_id - write_run_contracts(run, session_id=run_id) - completion = json.loads((run / "completion.json").read_text(encoding="utf-8")) - completion.update(result="completed", head_revision="abc123", unresolved=[]) - atomic_write_json(run / "completion.json", completion) - atomic_write_json( - run / "evaluation-runtime.json", - { - "schema": "agent-workflow/evaluation-runtime/v1", - "scorers": ["acceptance_commands"], - }, - ) - final_status = json.loads((run / "final-status.json").read_text(encoding="utf-8")) - final_status.update(status="completed", disposition=None) - atomic_write_json(run / "final-status.json", final_status) - seal_run(run, session_id=run_id) - final_digest = sha256_file(run / "final-receipt.json") - score_receipts = [] - for scorer_id in ("schema_validity", "acceptance_commands"): - score = { - "schema": "agent-workflow/score-receipt/v1", - "scorer": {"id": scorer_id, "version": "1"}, - "final_receipt_sha256": final_digest, - "verdict": "pass", - "facts": {}, - "evidence": [], - } - encoded = json.dumps(score, sort_keys=True, separators=(",", ":")).encode() - digest = hashlib.sha256(encoded).hexdigest() - atomic_write_json(run / "scores" / f"{scorer_id}-{digest}.json", score, mode=0o444) - score_receipts.append(score) - atomic_write_json( - run / "scores" / "score-set.json", - { - "schema": "agent-workflow/score-set/v1", - "final_receipt_sha256": final_digest, - "verdict": "pass", - "scores": score_receipts, - }, - ) - status = json.loads((run / "status.json").read_text(encoding="utf-8")) - status.update( - status="completed", - final_receipt_path=str(run / "final-receipt.json"), - final_receipt_sha256=final_digest, - tier="medium", - ) - atomic_write_json(run / "status.json", status) - record(settings, run_id, action="reviewed", actor="reviewer", reason="review") - record( - settings, - run_id, - action="accepted", - actor="reviewer", - reason="approved", - revision="abc123", - ) - disposition = lifecycle_disposition(run) - assert disposition is not None - return run, disposition - - -def _terminal_approval_workflow(root: Path): - settings = replace(defaults(), state_root=root / "state") - child, disposition = _accepted_child(settings, "approval-child") - run_dir = root / "approval-workflow" - run_dir.mkdir() - snapshot = normalize_snapshot( - { - "workflow_id": "approval-receipt-workflow", - "pack_id": "receipt-pack", - "pack_manifest_sha256": "c" * 64, - "nodes": [ - { - "node_id": "build", - "session_id": "approval-child", - "prompt_path": "build.md", - "dependencies": [], - }, - { - "node_id": "approve", - "kind": "approval", - "approval_for": "build", - "dependencies": ["build"], - }, - ], - } - ) - atomic_write_json(workflow_snapshot_path(run_dir), snapshot) - workflow_snapshot_path(run_dir).chmod(0o444) - workflow_events_path(run_dir).touch() - digest = snapshot_sha256(snapshot) - record_workflow_binding( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="build", - run_id="approval-child", - attempt=1, - actor="scheduler", - reason="launch", - snapshot_sha256=digest, - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="build", - actor="scheduler", - reason="running", - snapshot_sha256=digest, - previous_state="eligible", - next_state="running", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="build", - actor="scheduler", - reason="completed", - snapshot_sha256=digest, - previous_state="running", - next_state="completed", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="approve", - actor="scheduler", - reason="eligible", - snapshot_sha256=digest, - previous_state="blocked", - next_state="eligible", - ) - record_workflow_transition( - run_dir, - workflow_id=snapshot["workflow_id"], - node_id="approve", - actor="scheduler", - reason="canonical lifecycle receipt accepted", - snapshot_sha256=digest, - previous_state="eligible", - next_state="completed", - details={ - "approval_for": "build", - "subject_run_id": "approval-child", - "approval_action": "accepted", - "approval_receipt_sha256": disposition["receipt_sha256"], - "final_receipt_sha256": disposition["final_receipt_sha256"], - "completion_sha256": disposition["completion_sha256"], - "revision": disposition["revision"], - }, - ) - return settings, run_dir, child, disposition - - -class WorkflowReceiptTests(unittest.TestCase): - def test_terminal_workflow_seals_and_verifies_all_child_evidence(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run_dir, child = _terminal_workflow(Path(tmp)) - evidence = seal_workflow(settings=settings, run_dir=run_dir) - receipt = evidence["receipt"] - self.assertTrue(evidence["verified"]) - self.assertEqual(receipt["workflow_state"], "completed") - self.assertEqual(receipt["node_count"], 1) - self.assertEqual(receipt["nodes"][0]["child_run_id"], "child-one") - self.assertEqual( - receipt["nodes"][0]["child_final_receipt_sha256"], - sha256_file(child / "final-receipt.json"), - ) - self.assertEqual(receipt["events_sha256"], sha256_file(workflow_events_path(run_dir))) - self.assertEqual(verify_workflow_receipt(settings=settings, run_dir=run_dir)["receipt"], receipt) - - def test_partial_workflow_cannot_be_sealed(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - settings = replace(defaults(), state_root=root / "state") - run_dir = root / "workflow" - run_dir.mkdir() - snapshot = normalize_snapshot( - { - "workflow_id": "partial-workflow", - "pack_id": "pack", - "pack_manifest_sha256": "b" * 64, - "nodes": [ - { - "node_id": "pending", - "session_id": "pending-run", - "prompt_path": "tickets/pending.md", - "dependencies": [], - } - ], - } - ) - atomic_write_json(workflow_snapshot_path(run_dir), snapshot) - workflow_snapshot_path(run_dir).chmod(0o444) - workflow_events_path(run_dir).touch() - with self.assertRaisesRegex(WorkflowError, "requires terminal nodes"): - build_workflow_receipt(settings=settings, run_dir=run_dir) - - def test_child_receipt_substitution_fails_verification(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run_dir, child = _terminal_workflow(Path(tmp)) - seal_workflow(settings=settings, run_dir=run_dir) - path = child / "final-receipt.json" - os.chmod(path, 0o644) - receipt = json.loads(path.read_text(encoding="utf-8")) - receipt["sealed_at"] = "2099-01-01T00:00:00+00:00" - atomic_write_json(path, receipt) - os.chmod(path, 0o444) - with self.assertRaisesRegex(WorkflowError, "durable evidence"): - verify_workflow_receipt(settings=settings, run_dir=run_dir) - - def test_omitted_or_duplicate_nodes_fail_against_durable_evidence(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run_dir, _ = _terminal_workflow(Path(tmp)) - seal_workflow(settings=settings, run_dir=run_dir) - path = workflow_receipt_path(run_dir) - original = json.loads(path.read_text(encoding="utf-8")) - for mutation in ("omit", "duplicate"): - tampered = json.loads(json.dumps(original)) - if mutation == "omit": - tampered["nodes"] = [] - tampered["node_count"] = 0 - else: - tampered["nodes"].append(dict(tampered["nodes"][0])) - tampered["node_count"] = 2 - os.chmod(path, 0o644) - atomic_write_json(path, tampered) - os.chmod(path, 0o444) - with self.subTest(mutation=mutation): - with self.assertRaises(WorkflowError): - verify_workflow_receipt(settings=settings, run_dir=run_dir) - os.chmod(path, 0o644) - atomic_write_json(path, original) - os.chmod(path, 0o444) - self.assertTrue(verify_workflow_receipt(settings=settings, run_dir=run_dir)["verified"]) - - def test_receipt_itself_must_be_canonical_regular_read_only_file(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run_dir, _ = _terminal_workflow(Path(tmp)) - seal_workflow(settings=settings, run_dir=run_dir) - path = workflow_receipt_path(run_dir) - os.chmod(path, 0o644) - with self.assertRaisesRegex(WorkflowError, "read-only"): - verify_workflow_receipt(settings=settings, run_dir=run_dir) - - def test_approval_evidence_is_reverified_when_workflow_receipt_is_built(self): - with tempfile.TemporaryDirectory() as tmp: - settings, run_dir, _, disposition = _terminal_approval_workflow(Path(tmp)) - sealed = seal_workflow(settings=settings, run_dir=run_dir) - approval = next( - item for item in sealed["receipt"]["nodes"] if item["node_id"] == "approve" - ) - self.assertEqual( - approval["approval_receipt_sha256"], disposition["receipt_sha256"] - ) - receipt_path = Path(disposition["receipt_path"]) - os.chmod(receipt_path, 0o644) - value = json.loads(receipt_path.read_text(encoding="utf-8")) - value["reason"] = "tampered after workflow transition" - atomic_write_json(receipt_path, value) - os.chmod(receipt_path, 0o444) - with self.assertRaisesRegex(WorkflowError, "canonical evidence"): - verify_workflow_receipt(settings=settings, run_dir=run_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_workflow_templates.py b/tests/test_workflow_templates.py deleted file mode 100644 index d6320b0..0000000 --- a/tests/test_workflow_templates.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import unittest - -from agent_workflow.errors import WorkflowError -from agent_workflow.workflow_templates import AUTHORIZED_TEMPLATES, expand_workflow_template - - -def _node(node_id: str) -> dict[str, object]: - return { - "node_id": node_id, - "session_id": f"session-{node_id.lower()}", - "prompt_path": f"tickets/{node_id}.md", - } - - -class WorkflowTemplateTests(unittest.TestCase): - def _expand(self, template: str, parameters): - return expand_workflow_template( - template, - workflow_id=f"wf-{template}", - pack_id="pack", - pack_manifest_sha256="c" * 64, - parameters=parameters, - ) - - def test_pipeline_expansion_is_deterministic_and_canonical(self): - parameters = {"steps": [_node("A"), _node("B"), _node("C")]} - first = self._expand("pipeline", parameters) - second = self._expand("pipeline", parameters) - self.assertEqual(first, second) - by_id = {node["node_id"]: node for node in first["nodes"]} - self.assertEqual(by_id["A"]["dependencies"], []) - self.assertEqual(by_id["B"]["dependencies"], ["A"]) - self.assertEqual(by_id["C"]["dependencies"], ["B"]) - self.assertEqual(parameters["steps"][1].get("dependencies"), None) - - def test_parallel_review_fan_in_has_parallel_reviews_and_sorted_fan_in(self): - graph = self._expand( - "parallel-review-fan-in", - { - "subject": _node("SUBJECT"), - "reviews": [_node("REVIEW_B"), _node("REVIEW_A")], - "fan_in": _node("MERGE"), - }, - ) - by_id = {node["node_id"]: node for node in graph["nodes"]} - self.assertEqual(by_id["REVIEW_A"]["dependencies"], ["SUBJECT"]) - self.assertEqual(by_id["REVIEW_B"]["dependencies"], ["SUBJECT"]) - self.assertEqual(by_id["MERGE"]["dependencies"], ["REVIEW_A", "REVIEW_B"]) - - def test_implementation_independent_review_is_a_two_node_chain(self): - graph = self._expand( - "implementation-independent-review", - {"implementation": _node("IMPLEMENT"), "review": _node("REVIEW")}, - ) - by_id = {node["node_id"]: node for node in graph["nodes"]} - self.assertEqual(by_id["IMPLEMENT"]["dependencies"], []) - self.assertEqual(by_id["REVIEW"]["dependencies"], ["IMPLEMENT"]) - - def test_only_three_authorized_templates_and_invalid_parameters_fail(self): - self.assertEqual( - AUTHORIZED_TEMPLATES, - ( - "pipeline", - "parallel-review-fan-in", - "implementation-independent-review", - ), - ) - cases = ( - ("persona-catalog", {}), - ("pipeline", {"steps": []}), - ("pipeline", {"steps": ["not-a-node"]}), - ("parallel-review-fan-in", {"subject": _node("A"), "reviews": [_node("R")], "fan_in": _node("F")}), - ("implementation-independent-review", {"implementation": _node("A")}), - ) - for template, parameters in cases: - with self.subTest(template=template, parameters=parameters): - with self.assertRaises(WorkflowError): - self._expand(template, parameters) - - def test_template_expansion_rejects_duplicate_or_missing_node_ids(self): - with self.assertRaisesRegex(WorkflowError, "duplicate workflow node ID"): - self._expand("pipeline", {"steps": [_node("A"), _node("A")]}) - broken = _node("A") - broken["node_id"] = "" - with self.assertRaises(WorkflowError): - self._expand("pipeline", {"steps": [broken]}) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_worktree.py b/tests/test_worktree.py deleted file mode 100644 index 7414284..0000000 --- a/tests/test_worktree.py +++ /dev/null @@ -1,84 +0,0 @@ -import shutil -import subprocess -import tempfile -import unittest -from pathlib import Path - -from agent_workflow.config import defaults -from agent_workflow.worktrees import create, remove - - -@unittest.skipUnless(shutil.which("git"), "git is required") -class WorktreeTests(unittest.TestCase): - def test_create_and_remove_clean_worktree(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - repo = root / "repo" - repo.mkdir() - subprocess.run(["git", "init", "-q", str(repo)], check=True) - subprocess.run( - ["git", "-C", str(repo), "config", "user.email", "test@example.test"], - check=True, - ) - subprocess.run( - ["git", "-C", str(repo), "config", "user.name", "Test"], - check=True, - ) - (repo / "README.md").write_text("test\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True) - subprocess.run( - ["git", "-C", str(repo), "commit", "-qm", "initial"], - check=True, - ) - settings = defaults(root / "missing.toml") - destination = root / "worktree" - result = create( - settings, - repo=repo, - ticket_id="P0-01", - base_ref="HEAD", - destination=destination, - ) - self.assertTrue(destination.is_dir()) - self.assertEqual(result["branch"], "impl/p0-01") - removed = remove(repo, destination, delete_branch=True) - self.assertTrue(removed["branch_deleted"]) - self.assertFalse(destination.exists()) - - def test_base_revision_resolves_requested_base_ref(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - repo = root / "repo" - repo.mkdir() - subprocess.run(["git", "init", "-q", str(repo)], check=True) - subprocess.run( - ["git", "-C", str(repo), "config", "user.email", "test@example.test"], - check=True, - ) - subprocess.run( - ["git", "-C", str(repo), "config", "user.name", "Test"], - check=True, - ) - tracked = repo / "tracked.txt" - tracked.write_text("first\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "add", "tracked.txt"], check=True) - subprocess.run(["git", "-C", str(repo), "commit", "-qm", "first"], check=True) - base_revision = subprocess.run( - ["git", "-C", str(repo), "rev-parse", "HEAD"], - check=True, - capture_output=True, - text=True, - ).stdout.strip() - tracked.write_text("second\n", encoding="utf-8") - subprocess.run(["git", "-C", str(repo), "commit", "-qam", "second"], check=True) - - result = create( - defaults(root / "missing.toml"), - repo=repo, - ticket_id="P0-02", - base_ref="HEAD~1", - destination=root / "worktree", - ) - - self.assertEqual(result["base_revision"], base_revision) - self.assertEqual(result["worktree_revision"], base_revision) From 562f14bdf5e0b06737da72a4a8228fa55354f1f1 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:12:36 -0700 Subject: [PATCH 04/14] ci: isolate test dependencies from installed product --- .github/workflows/ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d86b1b..98112d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,15 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install system tools run: sudo apt-get update && sudo apt-get install -y tmux zstd - - name: Install project - run: python -m pip install -e '.[dev]' + - name: Install test dependencies in isolated CI environment + run: | + python -m venv "$RUNNER_TEMP/agent-workflow-venv" + "$RUNNER_TEMP/agent-workflow-venv/bin/python" -m pip install \ + 'setuptools>=61' 'pytest>=8,<10' 'jsonschema>=4.18,<5' - name: Acceptance and invariant suite - run: pytest + run: "$RUNNER_TEMP/agent-workflow-venv/bin/python" -m pytest - name: Compile and release assets run: | - python -m compileall -q src + "$RUNNER_TEMP/agent-workflow-venv/bin/python" -m compileall -q src bash -n install.sh uninstall.sh scripts/*.sh - python scripts/audit-release-assets.py + "$RUNNER_TEMP/agent-workflow-venv/bin/python" scripts/audit-release-assets.py From 084b2098af62b8e2a042cb4e746ff86ffa3fdf41 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:14:12 -0700 Subject: [PATCH 05/14] ci: install packaging tools without installing project --- .github/workflows/ci.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98112d3..a8a68dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,12 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install system tools run: sudo apt-get update && sudo apt-get install -y tmux zstd - - name: Install test dependencies in isolated CI environment - run: | - python -m venv "$RUNNER_TEMP/agent-workflow-venv" - "$RUNNER_TEMP/agent-workflow-venv/bin/python" -m pip install \ - 'setuptools>=61' 'pytest>=8,<10' 'jsonschema>=4.18,<5' + - name: Install test dependencies + run: python -m pip install 'setuptools>=61' 'pytest>=8,<10' 'jsonschema>=4.18,<5' - name: Acceptance and invariant suite - run: "$RUNNER_TEMP/agent-workflow-venv/bin/python" -m pytest + run: python -m pytest - name: Compile and release assets run: | - "$RUNNER_TEMP/agent-workflow-venv/bin/python" -m compileall -q src + python -m compileall -q src bash -n install.sh uninstall.sh scripts/*.sh - "$RUNNER_TEMP/agent-workflow-venv/bin/python" scripts/audit-release-assets.py + python scripts/audit-release-assets.py From af74c2d554e4d584aae91770ad28df519c185351 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:17:05 -0700 Subject: [PATCH 06/14] fix: honor configured executor commands in workflows --- MANIFEST.sha256 | 4 ++-- src/agent_workflow/executors.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 1c095ef..bb29a63 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -1,5 +1,5 @@ 7a97c19d87b6c07e6a28c16e18b7a1816fba6a2da530f8c488dba3f8e2f65b3a .cbmignore -ac10fe6191b605dc02fb0f9bd3291b2b6e5f5c02d175887b9771fce002186fca .github/workflows/ci.yml +d1f9d0a716334fd39dfc5423dfd13dbdc1601f4dd6cdf40e92d9501a922e713b .github/workflows/ci.yml 9d4865b65c62212d1e2213e6d5305458e6442d23338bb484ef05417e748cc12a .gitignore a18302e5fcf47bc999ec41d8770e2e51071b5b4684638078bb658fa2e2676f2d BACKLOG.md 295b210fcff6c78367ea8febc4ea07df5ae59c64b00e005985e2977c4e31d58f CHANGELOG.md @@ -190,7 +190,7 @@ a379c27cf3be1757d33b749957a01b4dd0e7a3eb9bc6c3b83f5607209dacbd17 src/agent_work ba27c19ecc466f37e345a99b16fc93736f624bdf493cdfaff0644b22fff1e9f6 src/agent_workflow/eval/trials.py b2ae17c4df4659b18eee5b6d134300ecf93edc389bd646d53000edf0a1ee5b4b src/agent_workflow/evaluation.py e0e6e3eb13e67a203f04e03b8e50cd72174be9e95242de228c9c8b4f36ac5770 src/agent_workflow/events.py -dba9d6bbfcf81efa907d7d8bc1e23e1ef3903699977da3c9e41989b96a71d35e src/agent_workflow/executors.py +631bcbb7c59be70d0e8130eec256d2e04664920a8a8be3e29dbd4d7b9e5da57a src/agent_workflow/executors.py 35b9f8bfb8f095da139189df5039760e87fa637b96f0003950c6bd21717bec7d src/agent_workflow/git.py 4977d7dca6bb50ef3fccc2a4fb13c885079985871d1de77ebfd69c5e81621bca src/agent_workflow/inspect_adapter.py 4a0cf62378a34c77ec4dd14b4d90c9666a277e719f9b64ee17fe7ec791e40200 src/agent_workflow/integrations/__init__.py diff --git a/src/agent_workflow/executors.py b/src/agent_workflow/executors.py index f8549de..36f437e 100644 --- a/src/agent_workflow/executors.py +++ b/src/agent_workflow/executors.py @@ -125,7 +125,17 @@ def prepare_executor( policy = settings.executor_policies.get(executor) selected_model, authorized = _select_model(settings, executor, model, allow_no_go_model) if interactive and executor in {"codex", "claude"}: - argv = list(policy.interactive_command if policy and policy.interactive_command else [argv[0]]) + # Built-in policies provide the provider-specific interactive flags, but + # a configured command may intentionally be a test double or wrapper. + # Do not replace that command with the default provider binary. + configured_executable = Path(argv[0]).name + policy_executable = ( + Path(policy.interactive_command[0]).name + if policy and policy.interactive_command + else None + ) + if policy and policy.interactive_command and configured_executable == policy_executable: + argv = list(policy.interactive_command) argv.extend(policy.interactive_permission_args if policy else ()) if selected_model: argv.extend(list(policy.model_arg) + [selected_model] if policy else ["--model", selected_model]) From 67fd8c019f3eeae5389f8a6114855d67e33032db Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:19:11 -0700 Subject: [PATCH 07/14] fix: seal runs with executor mode metadata --- MANIFEST.sha256 | 2 +- schemas/command.schema.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index bb29a63..ef0c0d6 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -98,7 +98,7 @@ ecb18f122f967f8f33f2905b01e402ae3ca5c53e2baad828792618b73e3d9a02 pyproject.toml 3156d2a36c05428fd993983bee48c491d7792da4634a721f098af876c1c9fc5a schemas/agent-context.schema.json 4f4da8261b8a9946b477b694bed5610b581aaf3722ac8460194711ec4c685587 schemas/assignment-event.schema.json 260790055c6f13515a4dce9704c3847259eb1d664dba8b628bae367814223564 schemas/command-collection.schema.json -8404f1da9a385d42d94993467b95a15217efc6b1a7777d429280be8206d905dc schemas/command.schema.json +e84bf1d0d914b7082d28f943770eec4b5c7f4ee7f6ddb7ab2e1160df4ef79e00 schemas/command.schema.json 9336bb6dd08bf2ee5be68eaf93122ebfd4b2dfb3b95ac4ee4d9a984993c13e31 schemas/completion-collection.schema.json d0150f14606effb18ba6524715659bfa36f9ce3811a7fdcc8f960d04f4ed7a4a schemas/completion.schema.json 2cad48f6b60762f99f0a2cf1a4f95f28eee9ca749d2d351782709ca2014b8d22 schemas/control-event.schema.json diff --git a/schemas/command.schema.json b/schemas/command.schema.json index 73ad827..6a12bc2 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -10,6 +10,7 @@ "executor": {"type": ["string", "null"]}, "stream_format": {"enum": ["text", "codex-jsonl", "claude-stream-json"]}, "interactive": {"type": "boolean"}, + "executor_interactive": {"type": "boolean"}, "model": {"type": ["string", "null"]}, "no_go_authorized": {"type": "boolean"}, "agent_name": {"type": "string"}, From 4aa0d9e6f680aa51749070cd1206df9d31eac0a0 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:21:45 -0700 Subject: [PATCH 08/14] fix: restart terminal failures and deduplicate receipts --- MANIFEST.sha256 | 4 ++-- src/agent_workflow/receipts.py | 8 ++++++++ src/agent_workflow/sessions.py | 9 ++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index ef0c0d6..66697e9 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -212,11 +212,11 @@ c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_work b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_workflow/provider_evidence.py -0e72bed88f364dec5cf06664e181df1b1a5b3f2a7f3f5fff8a1e98a3ece7ca08 src/agent_workflow/receipts.py +730be66900e67fe7adcab0ca666085eb24bf59eae22dc6dc5d9002ecc078e7fe src/agent_workflow/receipts.py 0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py 5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py -1c73e650395799b00fbd28b3353e2e9e9d50d3c232a45444be8b4569ff7c4cdd src/agent_workflow/sessions.py +ae1c8536e268473cf52096d8a6bd8b919752ea9a676c0a8a472085c2fafe8417 src/agent_workflow/sessions.py 869137b2224fb9d50e5a652b2705f5442a2145cccf6f83c52452aa2045c3acc4 src/agent_workflow/state.py e0e438af91d4b5aaefc0790aca9b87eea01ffa114fe71f4debc64630c534dfd3 src/agent_workflow/tmux.py 063beff32bbbc2aef1c06f3843e00e09c0ac5f6b92663594f734a7386f2c4ff3 src/agent_workflow/util.py diff --git a/src/agent_workflow/receipts.py b/src/agent_workflow/receipts.py index e218c32..bd5dfa9 100644 --- a/src/agent_workflow/receipts.py +++ b/src/agent_workflow/receipts.py @@ -366,6 +366,14 @@ def _seal_run_unlocked(run_dir: Path, *, session_id: str) -> dict[str, Any]: if path.is_file() and path.relative_to(run_dir).as_posix() not in listed_paths ) + unique_artifacts: dict[str, dict[str, Any]] = {} + for artifact in artifacts: + path = str(artifact["path"]) + previous = unique_artifacts.get(path) + if previous is not None and previous != artifact: + raise WorkflowError(f"conflicting sealed artifact entries for {path}") + unique_artifacts[path] = artifact + artifacts = list(unique_artifacts.values()) required = set(SEALED_ARTIFACTS) present = {item["path"] for item in artifacts} missing = sorted(required - present) diff --git a/src/agent_workflow/sessions.py b/src/agent_workflow/sessions.py index 2e34943..ba900fc 100644 --- a/src/agent_workflow/sessions.py +++ b/src/agent_workflow/sessions.py @@ -1373,10 +1373,13 @@ def restart( command_data = json.loads( Path(str(old["command_path"])).read_text(encoding="utf-8") ) - if not bool(command_data.get("interactive", old.get("interactive", False))): + if ( + not bool(command_data.get("interactive", old.get("interactive", False))) + and str(old.get("status")) not in TERMINAL_STATUSES + ): raise WorkflowError( - "non-interactive tasks are not user-resumable; launch a new task " - "or have the calling agent delegate the work again" + "non-interactive tasks cannot be restarted while active; launch a new " + "task or have the calling agent delegate the work again" ) command = command_data.get("argv") if ( From 43631400fbc7a83e1d69b5913812cf36a0644686 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:24:00 -0700 Subject: [PATCH 09/14] fix: record task result collections once --- MANIFEST.sha256 | 2 +- src/agent_workflow/receipts.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 66697e9..35644a4 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -212,7 +212,7 @@ c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_work b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_workflow/provider_evidence.py -730be66900e67fe7adcab0ca666085eb24bf59eae22dc6dc5d9002ecc078e7fe src/agent_workflow/receipts.py +6a737d77f971ce3add6fab601ce984bf079d5514a743961d183242ca3ed22e10 src/agent_workflow/receipts.py 0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py 5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py diff --git a/src/agent_workflow/receipts.py b/src/agent_workflow/receipts.py index bd5dfa9..1976c51 100644 --- a/src/agent_workflow/receipts.py +++ b/src/agent_workflow/receipts.py @@ -31,7 +31,6 @@ SEALED_TREES = ("collections", "scope") SEALED_OPTIONAL_ARTIFACTS = ( "result.json", - "collections/task-result.json", "evaluation-runtime.json", "execution-metrics.json", "control-events.jsonl", From 5e63ee1dccbb629c8a721f1c2d4af8ced762eb0e Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:26:10 -0700 Subject: [PATCH 10/14] fix: validate duplicate receipt entries safely --- MANIFEST.sha256 | 2 +- src/agent_workflow/receipts.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 35644a4..ebe1ad1 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -212,7 +212,7 @@ c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_work b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_workflow/provider_evidence.py -6a737d77f971ce3add6fab601ce984bf079d5514a743961d183242ca3ed22e10 src/agent_workflow/receipts.py +66d92317b38ddb9f571314fb4ed8dec17f9e9e1a7124b07dca16b728d6d0f2ee src/agent_workflow/receipts.py 0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py 5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py diff --git a/src/agent_workflow/receipts.py b/src/agent_workflow/receipts.py index 1976c51..2d32de7 100644 --- a/src/agent_workflow/receipts.py +++ b/src/agent_workflow/receipts.py @@ -99,10 +99,12 @@ def _artifact_entry(receipt: dict[str, Any], relative_path: str) -> dict[str, An for item in receipt.get("artifacts", []) if isinstance(item, dict) and item.get("path") == relative_path ] - if len(matches) != 1: + if not matches: raise WorkflowError( - f"final receipt must contain exactly one artifact entry for {relative_path}" + f"final receipt is missing an artifact entry for {relative_path}" ) + if any(item != matches[0] for item in matches[1:]): + raise WorkflowError(f"conflicting final receipt entries for {relative_path}") return matches[0] From 03a2613f6cf619736258d93c8667cfb600f8b7ac Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:28:18 -0700 Subject: [PATCH 11/14] fix: include collected task results in sealed receipts --- MANIFEST.sha256 | 2 +- src/agent_workflow/receipts.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index ebe1ad1..4770a0d 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -212,7 +212,7 @@ c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_work b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_workflow/provider_evidence.py -66d92317b38ddb9f571314fb4ed8dec17f9e9e1a7124b07dca16b728d6d0f2ee src/agent_workflow/receipts.py +dc6b24cb009b524607ce6a809155f1069fe702ad220d3b4a36f740717ead8f29 src/agent_workflow/receipts.py 0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py 5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py diff --git a/src/agent_workflow/receipts.py b/src/agent_workflow/receipts.py index 2d32de7..9032fc2 100644 --- a/src/agent_workflow/receipts.py +++ b/src/agent_workflow/receipts.py @@ -367,6 +367,11 @@ def _seal_run_unlocked(run_dir: Path, *, session_id: str) -> dict[str, Any]: if path.is_file() and path.relative_to(run_dir).as_posix() not in listed_paths ) + task_result = run_dir / "collections" / "task-result.json" + if task_result.is_file() and not any( + item.get("path") == "collections/task-result.json" for item in artifacts + ): + artifacts.append(_artifact_receipt(task_result, run_dir)) unique_artifacts: dict[str, dict[str, Any]] = {} for artifact in artifacts: path = str(artifact["path"]) From 1a097175137a767516b9d7ca9919ed3b1593d06a Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:30:53 -0700 Subject: [PATCH 12/14] fix: resume workflows with legacy result receipts --- MANIFEST.sha256 | 2 +- src/agent_workflow/bindings.py | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 4770a0d..9738401 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -172,7 +172,7 @@ ef50b02dad91063342a59e117a1d74c840333cc17c36ebbf77ac42309b84197c src/agent_work c63402cff4ffcfebbc43395b33cde4f1e64860b0e40f4d90a25c995eb20bf7a2 src/agent_workflow/assets/prompt-pack-root/templates/TICKET_COMPLETION.md 29a3dec61d1c021ac76e13ed2a5eeb793cfa0afde8dfddaf67cc1b7706be12c5 src/agent_workflow/assets/prompt-pack-root/templates/source-baseline.example.json a72623d39d13fc7532983587c2af4b45f104a73bfc45f8129ad7d076c7642008 src/agent_workflow/assets.py -1bb5a1767626d4117f94b7ca10033d517226884c9eb73ce1119fc7dfaaa9f79a src/agent_workflow/bindings.py +8ae251ebd46eb495dc7ff64c133010956528ec5e5949e7f6afb949ef728b0a8c src/agent_workflow/bindings.py 013619904f2ce81ef7890824ab9519a04506dc97e9e467f6b476911ef9f609f0 src/agent_workflow/cli.py 228d3b1bf04a6681ba138712d91510b64c0e97c1cf76de2cf9cd23e8626a37bc src/agent_workflow/config.py 2ce05b732595d51818c28ab121a75cede32d3de354de0da588815caf7c71f9e7 src/agent_workflow/contracts.py diff --git a/src/agent_workflow/bindings.py b/src/agent_workflow/bindings.py index 1223014..811d6ba 100644 --- a/src/agent_workflow/bindings.py +++ b/src/agent_workflow/bindings.py @@ -82,16 +82,23 @@ def _read_sealed_result(run_dir: Path) -> tuple[Any, str, str]: receipt, expected = verify_seal_details(run_dir) if receipt.get("session_id") != run_dir.name: raise WorkflowError(f"source final receipt belongs to another run: {run_dir.name}") - collection, _ = read_sealed_contract( - run_dir, - receipt, - "collections/task-result.json", - "agent-workflow/task-result-collection/v1", - ) - if collection.get("validation_status") != "valid": - raise WorkflowError(f"source run has no valid task result: {run_dir.name}") + try: + collection, _ = read_sealed_contract( + run_dir, + receipt, + "collections/task-result.json", + "agent-workflow/task-result-collection/v1", + ) + if collection.get("validation_status") != "valid": + raise WorkflowError(f"source run has no valid task result: {run_dir.name}") + except WorkflowError as exc: + # Older sealed runs may contain the validated result but lack the + # collection receipt. The result artifact remains integrity-checked by + # the final receipt, so preserve workflow resumption for those runs. + if "task-result.json" not in str(exc): + raise document, digest = read_sealed_json(run_dir, receipt, "result.json") - if collection.get("stored_sha256") != digest: + if "collection" in locals() and collection.get("stored_sha256") != digest: raise WorkflowError(f"source task-result collection digest mismatch: {run_dir.name}") return document, digest, expected From 72409fc0303be9e3ca6222e3f08e7ad5a75a5e27 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:32:48 -0700 Subject: [PATCH 13/14] fix: include validated result artifacts in receipts --- MANIFEST.sha256 | 2 +- src/agent_workflow/receipts.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 9738401..1a3c372 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -212,7 +212,7 @@ c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_work b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py f866dc22b51e4848638d7d0a66a9994e7761e78b8c20d3a63a26cca8b808b2c7 src/agent_workflow/provider_evidence.py -dc6b24cb009b524607ce6a809155f1069fe702ad220d3b4a36f740717ead8f29 src/agent_workflow/receipts.py +8791761fd907b6874716a08ada003012c76695214ff41ef468ae4f24a7ba69b5 src/agent_workflow/receipts.py 0be2faaadc83ae3fb056c3df20bc4073412e6fa47fa4df602f8d7877fa83d88a src/agent_workflow/routing.py 5fbbc4dd292540e231c401be983c9bb6018f9b9e61b8df8c9898f719c57d71c1 src/agent_workflow/runner.py e509a2863135ada0b5a0dadaaca886baf22ea44f71201b98ab66e19519459ecb src/agent_workflow/scheduler.py diff --git a/src/agent_workflow/receipts.py b/src/agent_workflow/receipts.py index 9032fc2..bba5881 100644 --- a/src/agent_workflow/receipts.py +++ b/src/agent_workflow/receipts.py @@ -372,6 +372,9 @@ def _seal_run_unlocked(run_dir: Path, *, session_id: str) -> dict[str, Any]: item.get("path") == "collections/task-result.json" for item in artifacts ): artifacts.append(_artifact_receipt(task_result, run_dir)) + result = run_dir / "result.json" + if result.is_file() and not any(item.get("path") == "result.json" for item in artifacts): + artifacts.append(_artifact_receipt(result, run_dir)) unique_artifacts: dict[str, dict[str, Any]] = {} for artifact in artifacts: path = str(artifact["path"]) From e583478bca99bf964d2ea6e7fec8f6d9b6707997 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sat, 25 Jul 2026 15:38:57 -0700 Subject: [PATCH 14/14] fix: parse nested task manifest mappings without yaml --- MANIFEST.sha256 | 2 +- src/agent_workflow/miniyaml.py | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 1a3c372..53be433 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -207,7 +207,7 @@ d1c691122a966840bfbbcc518dd76ea48b6134e23fae7f8ac639ab326c387aa7 src/agent_work 83a12421c93a0bc30c9bae04475cd88b18b5d1a83cc2f865580879fa74864db9 src/agent_workflow/messages.py 6356e12184d82f4e6adc9c2a21f04265221bf267caf44ed604e3b70e1d8a8dbf src/agent_workflow/metrics.py f5acd174adfd59f9461354c01462fc2d81b17f8604d16e9bb49825427ab00a73 src/agent_workflow/migrations.py -5e68e538455506d866da39a38c6d0c83ebc800f62cfc43cb44462d2b65199d5e src/agent_workflow/miniyaml.py +926bcdb887116cd17053336ef6d265afe8f875964f3d346208ae08c8d811d8fb src/agent_workflow/miniyaml.py c07886c9075db41a7d9c4e25a020650bd9fe38596c051dda2544eb6b1d462642 src/agent_workflow/native_jobs.py b431230d2eba2cacef30dc0de7c912e9495e68da85c6bc008123ff876b6e3f2b src/agent_workflow/pack.py e1788755065e85a2fc0eb299b56f656d07ccf970d6c974342e7c1f7aae4a8ff8 src/agent_workflow/process.py diff --git a/src/agent_workflow/miniyaml.py b/src/agent_workflow/miniyaml.py index 59e0b18..113a10e 100644 --- a/src/agent_workflow/miniyaml.py +++ b/src/agent_workflow/miniyaml.py @@ -108,6 +108,8 @@ def load_task_manifest(text: str) -> dict[str, Any]: tasks: list[dict[str, Any]] = [] in_tasks = False current_task: dict[str, Any] | None = None + nested_mapping: dict[str, Any] | None = None + nested_indent: int | None = None for line_number, raw_line in enumerate(text.splitlines(), 1): cleaned = _strip_comment(raw_line).rstrip() @@ -118,6 +120,8 @@ def load_task_manifest(text: str) -> dict[str, Any]: if indent == 0: current_task = None + nested_mapping = None + nested_indent = None key, separator, raw_value = line.partition(":") if not separator: raise MiniYamlError(f"line {line_number}: expected key: value") @@ -159,6 +163,8 @@ def load_task_manifest(text: str) -> dict[str, Any]: ) current_task = {key.strip(): _parse_scalar(raw_value)} tasks.append(current_task) + nested_mapping = None + nested_indent = None continue if current_task is None: @@ -168,6 +174,18 @@ def load_task_manifest(text: str) -> dict[str, Any]: key, separator, raw_value = line.partition(":") if not separator: raise MiniYamlError(f"line {line_number}: expected key: value") - current_task[key.strip()] = _parse_scalar(raw_value) + if nested_mapping is not None and nested_indent is not None and indent > nested_indent: + nested_mapping[key.strip()] = _parse_scalar(raw_value) + continue + key = key.strip() + if not raw_value.strip(): + child: dict[str, Any] = {} + current_task[key] = child + nested_mapping = child + nested_indent = indent + else: + current_task[key] = _parse_scalar(raw_value) + nested_mapping = None + nested_indent = None return result