From aea2dbbf60df4dc79635a3e661158235ffa78a09 Mon Sep 17 00:00:00 2001 From: Alexandru Groza Date: Thu, 22 Oct 2026 07:40:14 +0000 Subject: [PATCH 1/2] feat(codex-sdk): add the uipath-codex-sdk integration Runs OpenAI Codex SDK agents on the UiPath platform. The bundled codex binary's model calls go through a loopback proxy to the tenant's LLM Gateway (OpenAI Responses passthrough), and UiPath tools are served to it from an in-process MCP tool host. - Runtime and factory on the uipath-runtime protocols, resumable through the SQLite state store: durable tools suspend the run and resume it with the platform's result, one job and one trace throughout. - UiPath tools: process and agent jobs, Action Center escalations, Integration Service activities, context grounding, DeepRAG, Batch Transform, IXP extraction with Document Validation, job attachments and Analyze Files; code factories and agent.json *_from_resource builders with argumentProperties. - MCP servers (stdio, remote, UiPath-hosted), structured output, built-in tools with a configurable sandbox. - Low-code trace schema, eval tool mocking and simulation, bindings overrides. - 19 samples, tests (offline e2e against the real codex binary). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 11 + packages/uipath-codex-sdk/.python-version | 1 + packages/uipath-codex-sdk/README.md | 1067 +++++++ packages/uipath-codex-sdk/pyproject.toml | 76 + .../samples/analyze-files-agent/.env.example | 5 + .../samples/analyze-files-agent/README.md | 58 + .../samples/analyze-files-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 49 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/analyze-files-agent/input.json | 8 + .../samples/analyze-files-agent/main.py | 42 + .../analyze-files-agent/pyproject.toml | 14 + .../samples/analyze-files-agent/uipath.json | 1 + .../samples/analyze-files-agent/upload.py | 33 + .../.env.example | 5 + .../README.md | 65 + .../codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 53 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../input.json | 8 + .../batch-transform-attachments-agent/main.py | 52 + .../pyproject.toml | 14 + .../reviews.csv | 6 + .../uipath.json | 1 + .../upload.py | 33 + .../samples/coding-agent/.gitignore | 1 + .../samples/coding-agent/README.md | 65 + .../samples/coding-agent/codex.json | 5 + .../samples/coding-agent/input.json | 3 + .../samples/coding-agent/main.py | 23 + .../samples/coding-agent/pyproject.toml | 14 + .../samples/coding-agent/uipath.json | 1 + .../context-grounding-agent/.env.example | 5 + .../samples/context-grounding-agent/README.md | 69 + .../context-grounding-agent/bindings.json | 26 + .../context-grounding-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 37 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../context-grounding-agent/input.json | 3 + .../samples/context-grounding-agent/main.py | 56 + .../context-grounding-agent/pyproject.toml | 14 + .../context-grounding-agent/uipath.json | 1 + .../samples/deep-rag-agent/.env.example | 5 + .../samples/deep-rag-agent/README.md | 69 + .../samples/deep-rag-agent/bindings.json | 26 + .../samples/deep-rag-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 59 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/deep-rag-agent/input.json | 3 + .../samples/deep-rag-agent/main.py | 55 + .../samples/deep-rag-agent/pyproject.toml | 14 + .../samples/deep-rag-agent/uipath.json | 1 + .../deep-rag-attachments-agent/.env.example | 5 + .../deep-rag-attachments-agent/README.md | 63 + .../deep-rag-attachments-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 51 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../deep-rag-attachments-agent/input.json | 8 + .../deep-rag-attachments-agent/main.py | 48 + .../deep-rag-attachments-agent/pyproject.toml | 14 + .../deep-rag-attachments-agent/uipath.json | 1 + .../deep-rag-attachments-agent/upload.py | 33 + .../samples/durable-process-tool/.env.example | 8 + .../samples/durable-process-tool/README.md | 147 + .../durable-process-tool/bindings.json | 26 + .../samples/durable-process-tool/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 101 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/durable-process-tool/input.json | 6 + .../samples/durable-process-tool/main.py | 54 + .../durable-process-tool/pyproject.toml | 14 + .../samples/durable-process-tool/uipath.json | 1 + .../samples/escalation-agent/.env.example | 5 + .../samples/escalation-agent/README.md | 106 + .../samples/escalation-agent/bindings.json | 26 + .../samples/escalation-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 147 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/escalation-agent/input.json | 5 + .../samples/escalation-agent/main.py | 77 + .../samples/escalation-agent/pyproject.toml | 14 + .../samples/escalation-agent/uipath.json | 1 + .../integration-tool-agent/.env.example | 5 + .../samples/integration-tool-agent/README.md | 128 + .../integration-tool-agent/bindings.json | 21 + .../samples/integration-tool-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 37 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/integration-tool-agent/input.json | 3 + .../samples/integration-tool-agent/main.py | 78 + .../integration-tool-agent/pyproject.toml | 14 + .../integration-tool-agent/uipath.json | 1 + .../samples/ixp-validation-agent/.env.example | 5 + .../samples/ixp-validation-agent/README.md | 72 + .../samples/ixp-validation-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 169 ++ .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/ixp-validation-agent/input.json | 7 + .../samples/ixp-validation-agent/main.py | 71 + .../ixp-validation-agent/pyproject.toml | 14 + .../samples/ixp-validation-agent/uipath.json | 1 + .../samples/ixp-validation-agent/upload.py | 33 + .../samples/jira-issue-agent/.env.example | 5 + .../samples/jira-issue-agent/README.md | 35 + .../samples/jira-issue-agent/bindings.json | 21 + .../samples/jira-issue-agent/codex.json | 5 + .../samples/jira-issue-agent/input.json | 5 + .../samples/jira-issue-agent/main.py | 99 + .../samples/jira-issue-agent/pyproject.toml | 14 + .../samples/jira-issue-agent/uipath.json | 1 + .../samples/local-mcp-agent/README.md | 55 + .../samples/local-mcp-agent/codex.json | 5 + .../eval-sets/evaluation-set-default.json | 85 + .../evaluators/contains-status.json | 10 + .../evaluators/tool-call-arguments.json | 21 + .../evaluators/tool-call-count.json | 15 + .../evaluators/tool-call-order.json | 15 + .../samples/local-mcp-agent/input.json | 3 + .../samples/local-mcp-agent/main.py | 33 + .../samples/local-mcp-agent/orders_server.py | 25 + .../samples/local-mcp-agent/pyproject.toml | 15 + .../samples/local-mcp-agent/uipath.json | 1 + .../samples/outlook-email-agent/.env.example | 5 + .../samples/outlook-email-agent/README.md | 35 + .../samples/outlook-email-agent/bindings.json | 21 + .../samples/outlook-email-agent/codex.json | 5 + .../samples/outlook-email-agent/input.json | 4 + .../samples/outlook-email-agent/main.py | 81 + .../outlook-email-agent/pyproject.toml | 14 + .../samples/outlook-email-agent/uipath.json | 1 + .../process-resource-agent/.env.example | 8 + .../samples/process-resource-agent/README.md | 97 + .../samples/process-resource-agent/codex.json | 5 + .../eval-sets/evaluation-set-mocked.json | 52 + .../evaluations/evaluators/contains.json | 11 + .../evaluators/tool-call-order.json | 10 + .../samples/process-resource-agent/input.json | 1 + .../samples/process-resource-agent/main.py | 46 + .../process-resource-agent/pyproject.toml | 14 + .../resources/add_shipping.json | 24 + .../process-resource-agent/uipath.json | 1 + .../samples/quickstart-agent/.env.example | 5 + .../samples/quickstart-agent/README.md | 40 + .../samples/quickstart-agent/codex.json | 5 + .../samples/quickstart-agent/input.json | 3 + .../samples/quickstart-agent/main.py | 8 + .../samples/quickstart-agent/pyproject.toml | 14 + .../samples/quickstart-agent/uipath.json | 1 + .../samples/remote-mcp-agent/README.md | 36 + .../samples/remote-mcp-agent/codex.json | 5 + .../samples/remote-mcp-agent/input.json | 3 + .../samples/remote-mcp-agent/main.py | 39 + .../remote-mcp-agent/orders_http_server.py | 26 + .../samples/remote-mcp-agent/pyproject.toml | 15 + .../samples/remote-mcp-agent/uipath.json | 1 + .../samples/slack-message-agent/.env.example | 5 + .../samples/slack-message-agent/README.md | 49 + .../samples/slack-message-agent/bindings.json | 21 + .../samples/slack-message-agent/codex.json | 5 + .../samples/slack-message-agent/input.json | 4 + .../samples/slack-message-agent/main.py | 91 + .../slack-message-agent/pyproject.toml | 14 + .../samples/slack-message-agent/uipath.json | 1 + .../structured-output-agent/.env.example | 4 + .../samples/structured-output-agent/README.md | 44 + .../structured-output-agent/codex.json | 5 + .../eval-sets/evaluation-set-default.json | 68 + .../evaluators/contains-topic.json | 11 + .../evaluators/json-similarity.json | 10 + .../structured-output-agent/input.json | 3 + .../samples/structured-output-agent/main.py | 23 + .../structured-output-agent/project.uiproj | 6 + .../structured-output-agent/pyproject.toml | 14 + .../structured-output-agent/uipath.json | 3 + .../samples/uipath-mcp-agent/README.md | 44 + .../samples/uipath-mcp-agent/codex.json | 5 + .../samples/uipath-mcp-agent/input.json | 3 + .../samples/uipath-mcp-agent/main.py | 49 + .../samples/uipath-mcp-agent/pyproject.toml | 14 + .../samples/uipath-mcp-agent/uipath.json | 1 + .../src/uipath_codex_sdk/__init__.py | 115 + .../src/uipath_codex_sdk/_cli/__init__.py | 0 .../_cli/_templates/codex.json.template | 5 + .../_cli/_templates/main.py.template | 16 + .../src/uipath_codex_sdk/_cli/cli_new.py | 59 + .../src/uipath_codex_sdk/_fixed_args.py | 675 +++++ .../src/uipath_codex_sdk/agent.py | 107 + .../src/uipath_codex_sdk/attachment_tools.py | 647 ++++ .../src/uipath_codex_sdk/attachments.py | 464 +++ .../uipath_codex_sdk/batch_transform_tools.py | 838 ++++++ .../src/uipath_codex_sdk/context_tools.py | 1758 +++++++++++ .../src/uipath_codex_sdk/escalation_tools.py | 975 ++++++ .../src/uipath_codex_sdk/integration_tools.py | 862 ++++++ .../src/uipath_codex_sdk/ixp_tools.py | 893 ++++++ .../src/uipath_codex_sdk/mcp.py | 243 ++ .../src/uipath_codex_sdk/middlewares.py | 8 + .../src/uipath_codex_sdk/process_tools.py | 162 + .../src/uipath_codex_sdk/runtime/__init__.py | 28 + .../src/uipath_codex_sdk/runtime/config.py | 56 + .../src/uipath_codex_sdk/runtime/errors.py | 82 + .../src/uipath_codex_sdk/runtime/factory.py | 601 ++++ .../runtime/gateway/__init__.py | 165 ++ .../uipath_codex_sdk/runtime/gateway/proxy.py | 326 ++ .../runtime/gateway/upstream.py | 90 + .../src/uipath_codex_sdk/runtime/loader.py | 143 + .../src/uipath_codex_sdk/runtime/mocking.py | 195 ++ .../src/uipath_codex_sdk/runtime/runtime.py | 2021 +++++++++++++ .../src/uipath_codex_sdk/runtime/schema.py | 321 ++ .../uipath_codex_sdk/runtime/session_store.py | 592 ++++ .../src/uipath_codex_sdk/runtime/storage.py | 241 ++ .../src/uipath_codex_sdk/runtime/telemetry.py | 1206 ++++++++ .../src/uipath_codex_sdk/runtime/tool_host.py | 1133 +++++++ .../src/uipath_codex_sdk/runtime/triggers.py | 67 + .../src/uipath_codex_sdk/tool_results.py | 62 + .../src/uipath_codex_sdk/tools.py | 963 ++++++ packages/uipath-codex-sdk/tests/__init__.py | 0 packages/uipath-codex-sdk/tests/_typing.py | 55 + .../tests/contract/__init__.py | 0 .../tests/contract/conftest.py | 63 + .../tests/contract/durable_mcp_host.py | 229 ++ .../tests/contract/harness.py | 449 +++ .../tests/contract/mock_responses.py | 260 ++ .../tests/contract/test_durable_contract.py | 525 ++++ .../tests/durable/__init__.py | 0 .../tests/durable/conftest.py | 142 + .../uipath-codex-sdk/tests/durable/fakes.py | 887 ++++++ .../tests/durable/test_attachments_e2e.py | 415 +++ .../test_batch_transform_attachments_e2e.py | 260 ++ .../tests/durable/test_cli.py | 116 + .../durable/test_deep_rag_attachments_e2e.py | 243 ++ .../tests/durable/test_deep_rag_e2e.py | 231 ++ .../tests/durable/test_e2e.py | 470 +++ .../tests/durable/test_escalation_e2e.py | 294 ++ .../tests/durable/test_integration_e2e.py | 319 ++ .../tests/durable/test_ixp_e2e.py | 312 ++ .../tests/durable/test_mocking_e2e.py | 536 ++++ .../durable/test_process_resource_e2e.py | 301 ++ .../tests/durable/test_resume_input.py | 162 + .../tests/durable/test_tracing.py | 587 ++++ .../tests/test_abandoned_stream.py | 66 + .../tests/test_analyze_files.py | 346 +++ .../tests/test_attachments.py | 375 +++ .../tests/test_batch_transform_attachments.py | 469 +++ .../tests/test_context_tools.py | 837 ++++++ packages/uipath-codex-sdk/tests/test_debug.py | 240 ++ .../tests/test_deep_rag_attachments.py | 337 +++ .../tests/test_escalation_tools.py | 612 ++++ .../uipath-codex-sdk/tests/test_factory.py | 238 ++ .../tests/test_immediate_tools_runtime.py | 115 + .../tests/test_integration_tools.py | 875 ++++++ .../uipath-codex-sdk/tests/test_ixp_tools.py | 137 + .../uipath-codex-sdk/tests/test_mocking.py | 1229 ++++++++ .../tests/test_prefilled_attachments.py | 542 ++++ .../tests/test_process_tools.py | 569 ++++ .../uipath-codex-sdk/tests/test_resume.py | 260 ++ .../uipath-codex-sdk/tests/test_schema.py | 203 ++ .../tests/test_session_store.py | 223 ++ .../uipath-codex-sdk/tests/test_storage.py | 115 + .../uipath-codex-sdk/tests/test_telemetry.py | 337 +++ .../uipath-codex-sdk/tests/test_tool_host.py | 760 +++++ packages/uipath-codex-sdk/tests/test_tools.py | 332 +++ .../uipath-codex-sdk/tests/test_watcher.py | 280 ++ packages/uipath-codex-sdk/uv.lock | 2638 +++++++++++++++++ 272 files changed, 40722 insertions(+) create mode 100644 packages/uipath-codex-sdk/.python-version create mode 100644 packages/uipath-codex-sdk/README.md create mode 100644 packages/uipath-codex-sdk/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/analyze-files-agent/upload.py create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/reviews.csv create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/upload.py create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/.gitignore create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/coding-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/context-grounding-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/upload.py create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/.env.example create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/README.md create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/codex.json create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/input.json create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/main.py create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/durable-process-tool/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/escalation-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/integration-tool-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/ixp-validation-agent/upload.py create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/jira-issue-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/eval-sets/evaluation-set-default.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/contains-status.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-arguments.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-count.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/orders_server.py create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/local-mcp-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/outlook-email-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/eval-sets/evaluation-set-mocked.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/contains.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/tool-call-order.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/resources/add_shipping.json create mode 100644 packages/uipath-codex-sdk/samples/process-resource-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/quickstart-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/orders_http_server.py create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/remote-mcp-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/bindings.json create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/slack-message-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/eval-sets/evaluation-set-default.json create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/contains-topic.json create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/json-similarity.json create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/project.uiproj create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/structured-output-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/samples/uipath-mcp-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/uipath-mcp-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/uipath-mcp-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/uipath-mcp-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/uipath-mcp-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/uipath-mcp-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/__init__.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/__init__.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/codex.json.template create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/main.py.template create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/cli_new.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/_fixed_args.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/attachment_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/attachments.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/batch_transform_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/context_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/escalation_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/integration_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/ixp_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/mcp.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/middlewares.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/process_tools.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/config.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/errors.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/__init__.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/proxy.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/upstream.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/loader.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/mocking.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/storage.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/tool_host.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/triggers.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/tool_results.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/tools.py create mode 100644 packages/uipath-codex-sdk/tests/__init__.py create mode 100644 packages/uipath-codex-sdk/tests/_typing.py create mode 100644 packages/uipath-codex-sdk/tests/contract/__init__.py create mode 100644 packages/uipath-codex-sdk/tests/contract/conftest.py create mode 100644 packages/uipath-codex-sdk/tests/contract/durable_mcp_host.py create mode 100644 packages/uipath-codex-sdk/tests/contract/harness.py create mode 100644 packages/uipath-codex-sdk/tests/contract/mock_responses.py create mode 100644 packages/uipath-codex-sdk/tests/contract/test_durable_contract.py create mode 100644 packages/uipath-codex-sdk/tests/durable/__init__.py create mode 100644 packages/uipath-codex-sdk/tests/durable/conftest.py create mode 100644 packages/uipath-codex-sdk/tests/durable/fakes.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_attachments_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_batch_transform_attachments_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_cli.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_deep_rag_attachments_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_deep_rag_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_escalation_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_integration_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_ixp_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_mocking_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_process_resource_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_resume_input.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_tracing.py create mode 100644 packages/uipath-codex-sdk/tests/test_abandoned_stream.py create mode 100644 packages/uipath-codex-sdk/tests/test_analyze_files.py create mode 100644 packages/uipath-codex-sdk/tests/test_attachments.py create mode 100644 packages/uipath-codex-sdk/tests/test_batch_transform_attachments.py create mode 100644 packages/uipath-codex-sdk/tests/test_context_tools.py create mode 100644 packages/uipath-codex-sdk/tests/test_debug.py create mode 100644 packages/uipath-codex-sdk/tests/test_deep_rag_attachments.py create mode 100644 packages/uipath-codex-sdk/tests/test_escalation_tools.py create mode 100644 packages/uipath-codex-sdk/tests/test_factory.py create mode 100644 packages/uipath-codex-sdk/tests/test_immediate_tools_runtime.py create mode 100644 packages/uipath-codex-sdk/tests/test_integration_tools.py create mode 100644 packages/uipath-codex-sdk/tests/test_ixp_tools.py create mode 100644 packages/uipath-codex-sdk/tests/test_mocking.py create mode 100644 packages/uipath-codex-sdk/tests/test_prefilled_attachments.py create mode 100644 packages/uipath-codex-sdk/tests/test_process_tools.py create mode 100644 packages/uipath-codex-sdk/tests/test_resume.py create mode 100644 packages/uipath-codex-sdk/tests/test_schema.py create mode 100644 packages/uipath-codex-sdk/tests/test_session_store.py create mode 100644 packages/uipath-codex-sdk/tests/test_storage.py create mode 100644 packages/uipath-codex-sdk/tests/test_telemetry.py create mode 100644 packages/uipath-codex-sdk/tests/test_tool_host.py create mode 100644 packages/uipath-codex-sdk/tests/test_tools.py create mode 100644 packages/uipath-codex-sdk/tests/test_watcher.py create mode 100644 packages/uipath-codex-sdk/uv.lock diff --git a/.gitignore b/.gitignore index 559b6b3a..51c92b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -190,6 +190,17 @@ playground.py **/samples/**/AGENTS.md **/samples/**/CLAUDE.md **/samples/**/bindings.json +# Hand-written bindings of the codex-sdk samples (process, connection and +# index binding examples). +!packages/uipath-codex-sdk/samples/durable-process-tool/bindings.json +!packages/uipath-codex-sdk/samples/integration-tool-agent/bindings.json +!packages/uipath-codex-sdk/samples/slack-message-agent/bindings.json +!packages/uipath-codex-sdk/samples/outlook-email-agent/bindings.json +!packages/uipath-codex-sdk/samples/jira-issue-agent/bindings.json +!packages/uipath-codex-sdk/samples/context-grounding-agent/bindings.json +!packages/uipath-codex-sdk/samples/deep-rag-agent/bindings.json +!packages/uipath-codex-sdk/samples/escalation-agent/bindings.json **/samples/**/entry-points.json +**/samples/**/traces.jsonl **/samples/**/uv.lock **/**/.claude/settings.local.json diff --git a/packages/uipath-codex-sdk/.python-version b/packages/uipath-codex-sdk/.python-version new file mode 100644 index 00000000..2c073331 --- /dev/null +++ b/packages/uipath-codex-sdk/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/packages/uipath-codex-sdk/README.md b/packages/uipath-codex-sdk/README.md new file mode 100644 index 00000000..c39853f0 --- /dev/null +++ b/packages/uipath-codex-sdk/README.md @@ -0,0 +1,1067 @@ +# uipath-codex-sdk + +Build and run [OpenAI Codex SDK](https://learn.chatgpt.com/docs/codex-sdk) agents on the +UiPath Cloud Platform. LLM traffic from the bundled `codex` binary is routed through the +UiPath LLM Gateway (OpenAI Responses API) via a local loopback proxy, so agents run with +your tenant's models and metering — no OpenAI API key required. + +## Quickstart + +```bash +uipath new my-agent # scaffold main.py + codex.json + pyproject.toml +uipath auth # authenticate against your tenant +uipath run agent '{"topic": "robots"}' +``` + +`main.py` exposes a module-level `UiPathCodexAgent`, and `codex.json` maps the entrypoint +to it: + +```python +from uipath_codex_sdk import UiPathCodexAgent + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", +) +``` + +```json +{ "agents": { "agent": "main.py:agent" } } +``` + +See [`samples/quickstart-agent`](samples/quickstart-agent). + +## The agent + +`UiPathCodexAgent` is plain configuration; the runtime builds and runs the +`codex` process from it. + +| Field | Default | What it does | +| --- | --- | --- | +| `model` | (required) | The tenant model the LLM Gateway serves, e.g. `gpt-5.6-terra` (see [Models](#models)) | +| `prompt` | `None` | The first user message, a `str.format` template over the input fields (`"Tell me a joke about {topic}."`). Without it, the input's `prompt`, `message`, `input` or `query` string is used, else the whole input as JSON | +| `input_schema` | `None` | Pydantic model: the input is validated against it and it becomes the entry point's input schema | +| `output_schema` | `None` | Pydantic model: Codex is asked for native structured output in that shape, and the result is validated against it | +| `base_instructions` | `None` | System/base instructions for the thread | +| `tools` | `None` | UiPath tools (process, escalation, Integration Service, context grounding, ...; the sections below) | +| `mcp_servers` | `None` | Your own MCP servers, by name (see [MCP servers](#mcp-servers)) | +| `sandbox` | `"read-only"` | Codex's sandbox for its built-in tools (see [Built-in tools and the sandbox](#built-in-tools-and-the-sandbox)) | +| `approval_mode` | `"deny_all"` | How Codex handles escalated permission requests; `"deny_all"` never asks | +| `codex_bin` | `None` | A specific `codex` binary instead of the bundled one (also `UIPATH_CODEX_BIN`) | +| `name` | `"agent"` | Display name in the agent graph | + +The remaining fields (`codex_home`, `hermetic`, `durable_tool_instructions`, +`durable_settle_timeout`) tune durable tools; their defaults suit almost every +agent. + +### Structured output + +```python +class Joke(BaseModel): + setup: str + punchline: str + rating: int = Field(description="How funny, 1-10.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", + input_schema=JokeInput, + output_schema=Joke, +) +``` + +The run's output is the validated `Joke` as JSON. Without an `output_schema` +it is `{"output": ""}`. See +[`samples/structured-output-agent`](samples/structured-output-agent). + +## Built-in tools and the sandbox + +Codex's own tools (shell commands, file edits, `apply_patch`) are always +available to the model; `sandbox` decides what they may do: + +| `sandbox` | Built-in tools can | Use it for | +| --- | --- | --- | +| `"read-only"` (default) | read files and run read-only commands | agents that only answer, or only use UiPath / MCP tools | +| `"workspace-write"` | write in the working directory | local runs; it needs Codex's Linux sandbox (namespaces, landlock), which serverless robots do not have | +| `"full-access"` | write files and run anything | coding agents, and local stdio MCP servers on a robot | + +On a serverless robot each job runs in its own working directory, and the +files it writes are discarded with the job. See +[`samples/coding-agent`](samples/coding-agent), which writes a Python file +and runs it. + +> **Security:** commands Codex runs inherit the job's environment, including +> `UIPATH_ACCESS_TOKEN` (the job's UiPath token) and the runtime's gateway +> key. Give `"full-access"` only to agents whose inputs you trust: a prompt +> injection in a document the agent reads could make it run a command that +> sends the token out. + +## MCP servers + +`mcp_servers` adds your own MCP servers, by name. Codex calls them directly, +so their tools run for real in evals too (they cannot be mocked). + +```python +from uipath_codex_sdk import RemoteMcpServer, StdioMcpServer, UiPathCodexAgent + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Look up the status of order '{order_id}'.", + sandbox="full-access", # a local stdio server is a process Codex starts + mcp_servers={ + "orders": StdioMcpServer(command="python", args=["orders_server.py"]), + "uipath_mcp": RemoteMcpServer( + url="https://cloud.uipath.com///agenthub_/mcp//", + bearer_token_env_var="UIPATH_ACCESS_TOKEN", + default_tools_approval_mode="approve", + ), + }, +) +``` + +* `StdioMcpServer(command, args, env)` starts a local server process. +* `RemoteMcpServer(url, bearer_token_env_var | bearer_token, http_headers, + startup_timeout_sec, tool_timeout_sec)` reaches a streamable-HTTP server. + Codex silently skips a remote server whose bearer token variable is not + set; the runtime warns when that would happen. +* A UiPath-hosted MCP server (Orchestrator → folder → MCP Servers → Copy URL) + authenticates with the job's own token: `bearer_token_env_var= + "UIPATH_ACCESS_TOKEN"`. Its tools carry no read-only annotation, so set + `default_tools_approval_mode="approve"`, or `"deny_all"` rejects every + call. +* `tool_approval_modes`, `supports_parallel_tool_calls` and + `omit_tools_from` pass through to Codex. + +The model sees a server's tools as `mcp____`; trace spans and +tool-call evaluators use the plain tool name. See +[`samples/local-mcp-agent`](samples/local-mcp-agent), +[`samples/remote-mcp-agent`](samples/remote-mcp-agent) and +[`samples/uipath-mcp-agent`](samples/uipath-mcp-agent). + +## Models + +Codex speaks only the OpenAI Responses API, and the runtime routes it to the +tenant's LLM Gateway as an OpenAI passthrough, so **only OpenAI models** work. +The tenant's Claude, Gemini and Llama models do not. `uipath list-models` +lists what the tenant serves; pass the name as the gateway reports it +(`gpt-5-mini-2025-08-07`, not `gpt-5-mini`). + +Verified with every sample: `gpt-5.6-terra`. Verified with the quickstart and +durable process samples: `gpt-6.1-sol` and `gpt-5-mini-2025-08-07`. Smaller +models follow tool instructions less reliably and can spend extra turns (for +example on shell commands) before calling the right tool. + +## Durable process and agent tools + +A durable tool runs an Orchestrator process or a deployed agent as a job, and +**suspends** the run until the job finishes. The run then resumes, possibly on +another robot, and the job's output becomes the result of the model's original +tool call. + +```python +from pydantic import BaseModel +from uipath_codex_sdk import UiPathCodexAgent, uipath_agent_tool, uipath_process_tool + + +class AddIn(BaseModel): + a: int + b: int + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Use a_plus_b to add {a} and {b}.", + tools=[ + uipath_process_tool( + "a_plus_b", + folder_path="Shared", + input_schema=AddIn, + description="Adds a and b.", + ), + uipath_agent_tool("Research_Agent", folder_path="Agents"), + ], +) +``` + +* `uipath run agent ...` suspends. `uipath run agent --resume` continues once the + jobs are done, and suspends again if one is still running. +* A successful job returns its output arguments. A faulted, stopped, stopping + or terminating job returns a `{"status": ...}` JSON result that the model can + act on. A job that fails to start comes back as a tool error. +* Several durable calls in one model response run in parallel. The run waits + for all of them. +* State lives in `__uipath/state.db`, including the Codex conversation. + An agent with durable tools runs on a run-scoped `CODEX_HOME` + (`__uipath/codex_home`, so `~/.codex` is not read) and with codex's ChatGPT + backend calls turned off (`hermetic`). Agents without durable tools are not + affected. +* The child job's `ParentSpanId` is the tool span. The job attachments in the + arguments' attachment fields are linked to the child job (see "Job + attachments"); BTS correlation (`parent_operation_id`) is not passed. +* Custom durable tools implement the `UiPathDurableTool` protocol: `name`, + `description`, `input_schema`, `validate_args`, `start` and `finish`. + `start` can return any suspend value the resume trigger handler knows + (`WaitJobRaw`, `WaitDeepRagRaw`, `WaitEscalation`). DeepRAG tasks have their own tool + (`uipath_deep_rag_tool`, under "Context grounding tools"). + +See [`samples/durable-process-tool`](samples/durable-process-tool) for a full +walkthrough, including local and tenant test steps. + +### Pre-filled arguments + +Some process inputs do not need to come from the model. `static_args` fixes +them, and `args_from_input` reads them from the agent input: + +```python +invoice = uipath_process_tool( + "CreateInvoice", + input_schema=InvoiceIn, # customerId, currency, amount, ... + static_args={"currency": "EUR", "lines[*].taxed": True}, + args_from_input={"customerId": "{{customer.id}}"}, +) +``` + +* Keys are paths into the process input: `a.b`, `items[*].id` (every + element), `items[*]` (every element), `items[0]`. LangChain's JSONPath + (`$['a']['b']`, `$.a.b`) works too. A field the input schema does not have + is refused. +* Static fields are hidden from the model, at any depth. `items[*]` hides the + array `items`. An index key such as `items[0]` stays visible: only its + value is merged. +* `args_from_input` fields stay visible to the model, but optional, with a + note saying where they come from. The agent input's value wins; when the + agent input has none, the model's value is sent, as in LangChain. A + required field neither of them fills is a tool error asking the model to + fill it. +* Pre-filled values overwrite the model's when the job starts. A static + `items[*]` on an empty or missing `items` becomes `[value]`. A pydantic + `input_schema` validates the merged input, so a pre-filled field can be + required. +* The `Tool call` span's input, the simulated `processTool` span's + `arguments` and the eval mocker see the merged input, as in low-code. + +### From an agent.json + +`process_tool_from_resource(resource)` builds the tool from a low-code +`agent.json` `tool` resource of `type` Process, Agent, Api, +ProcessOrchestration, Flow or Function (any case). Every one of them starts +an Orchestrator job, as in LangChain: + +| Tool field | From the resource | +| --- | --- | +| process name (and `process.` binding key) | `properties.processName` (required) | +| tool name | `name`, sanitized like low-code: `Basic RPA Process` becomes `Basic_RPA_Process` (`tool_name=` overrides it) | +| description | `description`, plus the output schema hint | +| `kind` | `agent` for `Agent`, `process` otherwise | +| `toolType` of the `Tool call` span | low-code's: `Process`, `Agent`, `Api`, `agenticProcess` (ProcessOrchestration), `Function`, and `Integration` for a Flow | +| input / output schema | `inputSchema` / `outputSchema` | +| pre-filled inputs | `argumentProperties` (below) | +| mock name, example calls | `name`, `properties.exampleCalls` | + +* The job runs in the run's folder (`UIPATH_FOLDER_PATH`), as in low-code: + `properties.folderPath` is ignored, and bindings redirect the process. + `folder_path=` sets the folder explicitly. `run_as_me=` is the caller's + choice (low-code sets it for conversational agents only). +* `settings` (timeout, retries), `requireConversationalConfirmation` and the + legacy `arguments` are ignored. A disabled resource is refused: filter it + out first. + +`argumentProperties` keys are paths into the process input, usually +JSONPath (`$['customer']['id']`): + +| `variant` | Value | +| --- | --- | +| `static` | `value`, hidden from the model | +| `argument` | the agent input at `argumentPath` (`$.customer.id`); visible but optional, the model's value is used when the agent input has none | +| `textBuilder` | `tokens` rendered against the agent input (`input.customer.name`), hidden | +| `arrayBuilder` | a list of its `name[0]`, `name[1]`, ... items (each one static, argument, text builder or array builder); a gap, or an argument item the agent input lacks, is `null`; a text-builder item keeps the text of a variable the agent input lacks; hidden | +| `objectBuilder` | refused, as in LangChain ("unsupported argument property type"), unless a parent path covers it | +| `dynamic` | refused (the resource does not validate); leave the key out to let the model fill it | + +A parent path wins over the paths under it (`customer` over +`customer.id`). A path the input schema does not have is ignored with a +warning. `isSensitive` values are hidden from the model like any pre-filled +value, and show in clear text in the spans, as in low-code. + +Escalation, Analyze Files, DeepRAG and Batch Transform resources read +`argumentProperties` the same way, as in LangChain: JSONPath keys, array +builders, parent wins, `static` / `textBuilder` / `arrayBuilder` fields +hidden at any depth, and an `argument` field visible but optional, with the +note. The agent input's value wins; when it has none, the model's value is +used, and a required field neither of them fills is a tool error asking the +model to fill it (nothing is started). Job attachments are checked once the +pre-filled values are merged in, as LangChain resolves them after its static +arguments: an attachment the agent input (or a static value) fills is the +one checked, and an unknown ID the model passed for that field is ignored; +an attachment field only the model fills (Analyze Files `attachments`, +DeepRAG / Batch Transform `attachment`, a process input, an app field) is +resolved against the run's known attachments like any attachment argument, +and an unknown ID there is still a tool error. An escalation's pre-filled +app fields are never checked (LangChain does not resolve an escalation's +attachments); a model value they replace is not checked either. One taken +from the agent input is matched to the known one by its `ID`. The `Tool call` span's input +and `attachments`, and the eval mocker, see the merged arguments; the +model's own arguments are what is saved for resume. + +See [`samples/process-resource-agent`](samples/process-resource-agent) for a +process tool built from a resource file, with an `argument` and a `static` +input. + +## Integration Service tools + +An Integration Service (IS) tool runs one activity of a connector, such as +GenAI Activities *Web Search* or Slack *Send Message*, on one of your +connections. It is an **immediate** tool: the call is answered as soon as +Integration Service returns, and the run never suspends on it. It is served +on the same `uipath_tools` MCP server as durable tools, so the two kinds can +be mixed, even in one model response. + +```python +from pydantic import BaseModel +from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool + + +class WebSearchIn(BaseModel): + query: str + + +web_search = uipath_integration_tool( + "web_search", + connection_id="", # also the bindings.json key + connector_key="uipath-uipath-airdk", + tool_path="/v2/webSearch", + method="POST", + input_schema=WebSearchIn, # POST: properties go to the body + static_args={"provider": "GoogleCustomSearch"}, # hidden from the model +) + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", prompt="Research {topic}.", tools=[web_search] +) +``` + +* The tool calls `UiPath().connections.invoke_activity_async(...)`, as + low-code agents do. The result goes back to the model as JSON text. +* Parameter locations: each `parameters` entry (low-code shape: `name`, + `fieldLocation` query/path/header/body/multipart/file, optional + `fieldVariant` and `value`) sets its own location. A `{x}` in `tool_path` + is a path parameter. The remaining input properties go to the body for + POST/PUT/PATCH and to the query string for GET/GETBYID/DELETE. +* `static` values (`static_args`, or `fieldVariant: "static"`) are hidden from + the model, at any depth (`provider`, `attachment.title`), and merged into + every call. A static `items[*]` sets every element of `items` and turns an + empty or missing `items` into `[value]`, as low-code does. +* `argument` parameters (`{{name}}`) take the agent input's value when it has + one. The model still sees the field, marked optional, and its value is sent + only when the agent input has none. +* A pydantic `input_schema` is validated and sent by alias, so + `filter: str = Field(alias="$filter")` goes out as `$filter`. +* To reuse a tool from a low-code agent, pass its `agent.json` resource + (`"type": "Integration"`) to `integration_tool_from_resource(resource)`. + You get the same tool name (sanitized like low-code: `Get User (v2)` becomes + `Get_User_v2`), activity metadata, enum hints and static parameters. +* Errors go back to the model as tool errors and never fail the run. A 400 or + 422 from the connector and timeouts are logged at WARNING, other HTTP errors + at ERROR. The host enforces `timeout_sec`, 300 s by default; `None` also + means 300 s, and there is no way to turn the timeout off. +* Connection bindings: the connection id is handed to the SDK unchanged on + every call, so a `connection` resource overwrite (bindings.json, keyed by + the id) rebinds it. Bindings for coded agents are written by hand. +* If a run suspends on a durable call while an IS call is still running, it + waits up to `durable_settle_timeout` for the IS call. If the call is still + running after that, it is cancelled and the model is told its outcome is + unknown. +* An agent with only immediate tools keeps its normal Codex configuration and + `CODEX_HOME`. +* Custom immediate tools implement the `UiPathTool` protocol: `name`, + `description`, `input_schema`, `validate_args`, and + `async call(args, ToolContext) -> str`, raising `UiPathToolError` for errors + the model should see. + +Not supported yet: discovering the input schema from the connector at +runtime, and converting job attachments into multipart file uploads. See +[`samples/integration-tool-agent`](samples/integration-tool-agent). + +## Context grounding tools + +A context tool runs a semantic search on one Context Grounding index and +returns the best matching chunks. It is an **immediate** tool on the same +`uipath_tools` MCP server: the call is answered when the search returns, and +the run never suspends on it. + +```python +from uipath_codex_sdk import UiPathCodexAgent, uipath_context_tool + +policies = uipath_context_tool( + "Company Policies", # display name; the model sees Company_Policies + index_name="company-policies", # a bindable `index` resource + description="HR and travel policies of the company.", + number_of_results=5, +) + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Answer from the company policies: {question}", + tools=[policies], +) +``` + +* The tool calls `UiPath().context_grounding.unified_search_async(...)`, as + low-code agents do. The model gets langchain's result shape: + `{"documents": [{"metadata": {"source", "search_id", "reference", + "page_number", "score"}, "page_content": "..."}]}`. Zero hits is + `{"documents": []}`, not an error. Results over 64 000 characters lose + whole documents from the end and get `"truncated": true`. +* Where the query comes from: + * the model, by default (a required `query` argument); + * a fixed value, `query="..."`: the model calls the tool with no arguments; + * the agent input, `query_from_input="topic"` (or `"{{topic}}"`, `"a.b"`): + the input's value wins, and the model's `query`, which stays visible but + optional, is used only when the input has none. + + `folder_path_prefix` / `folder_path_prefix_from_input` restrict the search + to one folder of the index's data source, and `file_extension` to one file + type (`All` means no filter). +* The index is looked up in `folder_path`, by default the job's folder + (`UIPATH_FOLDER_PATH`). The folder key is resolved asynchronously (the + SDK's own lookup is a blocking call), once per folder. +* Bindings: the index name and folder go through the SDK's `index` resource + override on every call, so an `index.[.]` overwrite + (bindings.json) rebinds them. +* Errors go back to the model as tool errors: a missing index, an index still + being ingested, a refused search (HTTP 400) or a timeout. +* To reuse a low-code agent's context, pass its `agent.json` resource + (`"$resourceType": "context"`) to `context_tool_from_resource(resource)`. + It maps `resultCount`, `threshold`, the query and folder prefix variants and + `fileExtension`. Like langchain, it does not use the resource's + `folderPath`, and it answers retrieval mode `Structured` with a semantic + search. +* Tracing: `Tool call - ` (`toolType` `ContextGrounding`, with + `index_name` and `context_retrieval_mode`) has a `contextGroundingTool` + child named after the display name, with the low-code attributes + (`retrieval_mode`, `query`, `threshold`, `number_of_results`, ...) and the + documents as `output`. Under it, a `ContextGroundingRetriever` span (an + OpenInference `RETRIEVER` span, as langchain's retriever records) carries + the query and the documents for the context precision and faithfulness + evaluators, and the SDK's own `contextgrounding_*` spans sit under that. + The retriever is a span of its own because LLMOps takes a span's type from + `openinference.span.kind` when it is set. +* In evals, the search is `mockable`, as in langchain: the search runs mocked + under the `contextGroundingTool` span (renamed `Simulated result: `), + with no `ContextGroundingRetriever` span. See "Mocking tools in evals". + +### DeepRAG (durable) + +`uipath_deep_rag_tool` runs a DeepRAG task on an index: the service +researches the documents and writes a synthesized answer with citations. A +task takes minutes, so the tool is **durable**: the call starts the task and +**suspends** the run, and the run resumes when the task ends, as for a +durable process tool. + +```python +from uipath_codex_sdk import uipath_deep_rag_tool + +research = uipath_deep_rag_tool( + "Invoice Research", + index_name="invoices", + folder_path="Coded", + citation_mode="Inline", # or "Skip" + description="Researches the company's invoices across documents.", +) +``` + +* The model gets langchain's DeepRAG result: + `{"text": "...", "citations": [{"ordinal", "page_number", "source", + "reference"}], "deepRagId": "..."}`. A failed task comes back as + `{"status": "failed", "error": "", "deepRagId": "..."}`, a result + the model can act on, not a faulted run. A start error (missing index, + index still ingesting, a refused task) is a tool error. +* The query, folder prefix and file extension work as for the search tool + (`query=`, `query_from_input=`, `folder_path_prefix`, `file_extension`); + the prefix and extension become the task's glob pattern + (`hr/*.pdf`). The model's `query` is the task prompt (at most 250 000 + characters). +* The tool starts the task itself (`start_deep_rag_async`) and suspends on a + `WaitDeepRagRaw`; the resume trigger is a DeepRAG trigger, which + Orchestrator fires when the task ends. +* `context_tool_from_resource` builds one from an `agent.json` context + resource with retrieval mode `DeepRAG`; `settings.citationMode` is + required, as in langchain. +* Tracing: the `Tool call` and its `contextGroundingTool` child + (`retrieval_mode` `DeepRAG`, `query`, `citation_mode`, `deep_rag_id`) stay + open across the suspension and close with the result in the execution that + uses it. +* In evals it is simulated as `Invoice Research` (the display / resource + name) with langchain's DeepRAG output schema, `{text, citations, + deepRagId}`; see "Mocking tools in evals". + +### DeepRAG on a job attachment (durable) + +The same DeepRAG task, on a file the run was given instead of an index: + +```python +from uipath_codex_sdk import uipath_deep_rag_attachments_tool + +research = uipath_deep_rag_attachments_tool("DeepRAG") # citation_mode="Inline" +``` + +* The model passes an `attachment` (a JobAttachment from the input or a tool + result; see "Job attachments") and a `query`. `query=` fixes the prompt + (hidden from the model). `query_from_input=` reads it from the agent input, + and `attachment_from_input=` takes the file from the agent input: the + agent input's value wins, and the model's is used when it has none (the + field stays visible, with a note; `attachment` becomes optional, and a + call with no file from either is a tool error). +* One call, `start_deep_rag_from_attachments_async`: the service builds a + temporary index of the file and starts the task, and the run suspends on it + as for an index. Same result and failure shape. The task runs in the job's + folder (`folder_path=` to change it); the service needs one, so a local run + sets `UIPATH_FOLDER_PATH`. DeepRAG reads PDF and TXT files; + another type is refused when the task starts, and the model gets the error. +* `deep_rag_tool_from_resource(resource)` builds one from an `agent.json` + internal tool with `toolType` `deep-rag` (Agent Builder's "DeepRAG"): its + query (static, dynamic or agent input), citation mode and the static, + argument and text-builder `argumentProperties`. `fileExtension` and + `folderPathPrefix` are ignored, as in langchain. A static query without a + value is refused when the tool is built. +* Differences from langchain: a start error is a tool error the model can act + on (langchain faults the run), and the `DisableDeepRagFromAttachments` kill + switch (langchain's older two-step path) is not read. +* In evals it is simulated by the name passed to the factory (or the resource + name), with the resource's `outputSchema` when it declares fields, else + DeepRAG's `{text, citations, deepRagId}`. +* Tracing: `Tool call - DeepRAG` (`toolType` `Internal`, `attachments`) and + its internal tool child `DeepRAG` (`input`, `deep_rag_id`, + `citation_mode`) stay open across the suspension and close with the + result. See [`samples/deep-rag-attachments-agent`](samples/deep-rag-attachments-agent). + +### Batch Transform on a job attachment (durable) + +Adds columns to a CSV the run was given, row by row: + +```python +from uipath_codex_sdk import uipath_batch_transform_attachments_tool + +enrich = uipath_batch_transform_attachments_tool( + "Enrich Reviews", + output_columns=[("sentiment", "positive, neutral or negative")], + web_search_grounding=False, +) +``` + +* The model passes an `attachment` (the CSV, a JobAttachment) and a `query` + (`query=` fixes it; `attachment_from_input=` takes the file from the agent + input, and the model's `attachment`, now optional, is used when the agent + input has none; a call with no file from either is a tool error). +* One call, `start_batch_transform_from_attachment_async` (the service + indexes the file and starts the task), and the run suspends on it. When it + ends, the tool downloads the result (through the service's authenticated + download endpoint, which every robot reaches; its storage URL is the + fallback) and uploads it as a job attachment (`output.csv`, `result_name=`), links it to the job, + and the model gets langchain's `{"result": {"ID", "FullName", "MimeType"}}`. + The result is then known to the run: the model can pass it to another + tool or return it in an attachment output field. A failed task fails the + run, as in langchain. +* The task runs in the job's folder (`folder_path=` to change it; a local run + sets `UIPATH_FOLDER_PATH`), and the result is uploaded to the job's folder. + The upload is retried; if linking it to the job fails, the uploaded file + is still returned. +* `batch_transform_tool_from_resource(resource)` builds one from an + `agent.json` internal tool with `toolType` `batch-transform`: its query + (static, or written by the model for any other variant, as in langchain), + output columns (at least one, each with a description), web search setting + and `argumentProperties`. `fileExtension` and the resource's + `outputSchema` are ignored, as in langchain; `folderPathPrefix` is ignored + too (langchain sends it; the attachment API has none). +* Tracing: `Tool call - ` (`toolType` `ContextGrounding`, the source + `in` and the result `out` as `attachments`) and its `contextGroundingTool` + child (`retrieval_mode` `BatchTransform`, `query`, `output_columns`, + `web_search_grounding`, `batch_transform_id`, and low-code's `results`) + stay open across the suspension. See + [`samples/batch-transform-attachments-agent`](samples/batch-transform-attachments-agent). +* In evals it is simulated by the name passed to the factory (or the resource + name) with langchain's `{"result": }` schema, not wrapped in + a second `result` as langchain's attachment tool does. The simulated result + attachment becomes known to the run but does not exist: a later real tool + that downloads it fails. +* Differences from langchain: a start error is a tool error, the trigger is + read by codex's own reader (status only; + the platform's downloads unauthenticated and unchecked), and the result + file is downloaded to `.uipath/batch_transform/.csv` (then + deleted) instead of `output.csv` in the working directory. + +Not supported yet: the BatchTransform retrieval mode on an index and Data +Fabric contexts. `context_tool_from_resource` refuses those resources, and +`attachments` context resources (use the internal `deep-rag` or +`batch-transform` tools above). + +## Action Center escalations (human in the loop) + +An escalation tool asks a person through an Action Center app. It is +**durable**: the call creates the task and **suspends** the run, and the run +resumes when the reviewer completes it, as for a durable process tool. + +```python +from pydantic import BaseModel +from uipath_codex_sdk import UiPathCodexAgent, uipath_escalation_tool + + +class Review(BaseModel): # the app's inputs, filled by the model + amount: float + reason: str + + +class Decision(BaseModel): # the app's outputs, returned to the model + Comment: str + + +approval = uipath_escalation_tool( + "ExpenseApproval", # the deployed app (a bindable `app` resource) + folder_path="Shared", + input_schema=Review, + output_schema=Decision, + title="Expense approval", + assignee="manager@example.com", + outcome_mapping={"Reject": "end"}, +) +``` + +* The model calls `escalate_` with the app's inputs. The tool creates + the task (`tasks.create_async`), assigns it, and suspends on a + `WaitEscalation`. Its resume trigger is a task trigger: Orchestrator + resumes the job when the task is completed. +* On resume the model gets langchain's escalation result: + `{"status": "completed", "outcome": "", "output": {}, "task_id": ..., "assigned_to": ""}`. + `output` keeps the output schema's fields (without one, every field that + is not an input). +* `outcome_mapping` maps an outcome to `continue` (the default) or `end`. An + `end` outcome ends the run with `TERMINATION_ESCALATION_REJECTED`, as + langchain does, and so does a deleted task. +* The task: `title` (or `title_from_input`, a path in the agent input), + `priority`, `labels`, and the actionable-message settings. Pre-filled + inputs, as for process tools: `static_args` (fixed values, hidden from the + model) and `args_from_input` (field → path in the agent input: visible but + optional, with a note; the agent input's value wins, the model's is used + when it has none, and a required field neither fills is a tool error, no + task is created). Both are merged into the task data. +* Who gets it: `assignee` (user name or email), or a `TaskRecipient` (user or + group by id, email or name, Workload, RoundRobin). The task is created and + then assigned. If the assignment fails, the task stays unassigned in Action + Center, the run still waits on it (no duplicate on a retry), and the span + records `assignmentError`. +* `escalation_tool_from_resource(resource)` builds one from an `agent.json` + `escalation` resource: its first channel (an Action Center app), the app + and folder, the input and output schemas, the task title (text, text + builder or agent-input path), the first recipient (including assets and + agent-input emails or groups), priority, labels, outcome mapping and the + static, argument, text-builder and array-builder `argumentProperties` + (read as for process tools; see "From an agent.json" there). +* Tracing: `Tool call - escalate_` (`toolType` `Escalation`) and its + `escalationTool` child, named after the app (`arguments`, `channelType`, + `taskId`, `taskUrl`, `assignedTo`), stay open across the suspension. Both + close with low-code's `{outcome, output}`; the `Tool call`'s `output.value` + keeps the full result the model received. +* Testing locally: `uipath run agent --resume` reads the real task (and + suspends again while it is open). To answer without Action Center, pass + the answer: `uipath run agent --resume '{"action": "Approve", "data": + {"Comment": "ok"}}'`. The real task then stays open in Action Center. +* In evals it is simulated by its model-facing name lowercased + (`escalate_expenseapproval`, langchain's) with langchain's + `{"action": "approve"|"reject", "data": {}, "is_deleted"}`; + the answer then goes through the same output filter and `outcome_mapping` + as a real task (the action is matched as given, so map `reject`, not + `Reject`, for a simulated `end`). No task is created. + +Not supported yet: QuickForm channels, escalation memory and a recipient chosen +by the model. +When a run ends, other open tasks stay in Action Center (there is no cancel +API). + +## IXP extraction and Document Validation + +Two durable tools that work as a pair, as in low-code agents: an IXP +extraction of a document, then a human check of it in Action Center's +Validation Station. + +```python +from uipath_codex_sdk import uipath_document_validation_tool, uipath_ixp_extraction_tool + +extract = uipath_ixp_extraction_tool( + "Extract Invoice", project_name="Invoices", version_tag="live" +) +validate = uipath_document_validation_tool( + "Validate Invoice", + extraction_tool=extract, + storage_bucket_name="invoice-validation", +) +``` + +* `Extract_Invoice`: the model passes a document (a JobAttachment, see "Job + attachments"). The tool downloads it, starts the extraction on the + project's tagged model version and suspends the run; on resume the model + gets the extracted fields (`dataProjection`). The full extraction is kept + with the run for the validation tool. +* `Validate_Invoice` takes no arguments: it creates a Validation Station task + for its extraction tool's last result (`storage_bucket_name`, + `storage_bucket_directory_path`, `action_folder`, `priority`, `title`) and + suspends; on resume the model gets the validated fields, `{"data": ...}`. + Called before the extraction has returned, it answers with an error the + model can act on. +* As in langchain: a document marked as an exception, or a deleted task, ends + the run with `TERMINATION_ESCALATION_REJECTED`; a call without a document, + a download or start error, and a failed extraction or validation operation + fail the run. +* From an `agent.json`: `ixp_extraction_tool_from_resource` (a `tool` with + `type: "Ixp"`) and `vs_escalation_tool_from_resource` (an `escalation` with + `escalationType: 1`, Agent Builder's "Document Validation Task"; + `escalation_tool_from_resource` routes those there too). The validation + tool names its IXP tool by the resource name (`ixpToolId`). The bucket is + looked up in `action_folder` only (the job's folder by default); from a + resource that is its `storageBucketFolderPath` (langchain sends it as a + directory inside the bucket instead, where the bucket is not found). +* Tracing: `Tool call - ` (`toolType` `IxpExtraction` / `Escalation`) + with low-code's `ixpTool` (`projectName`, `versionTag`, `extractionId`, + `documentId`, `result`) and `vsEscalationTool` (`ixpToolId`, + `storageBucketName`, `operationId`, `taskUrl`, `result`) children, held + across the suspension. See [`samples/ixp-validation-agent`](samples/ixp-validation-agent). + +* In evals the extraction is simulated by its display (resource) name with + langchain's `ExtractionResponseIXP` schema, and the validation by its + display name with `{"data": [] | null}`. A simulated + extraction is kept for the validation tool like a real one, so a real + validation task then shows the simulated fields; a simulated validation + still needs an extraction (real or simulated) first, as in langchain. + +Not supported yet: the `DocumentValidationStation` telemetry event and +classification projects. + +## Job attachments + +Orchestrator files a run receives, hands to tools and returns. As in +LangChain ReAct agents, the model never opens a file itself: it sees each +attachment as a JobAttachment object and passes it to the tools that accept +attachments. + +```python +from pydantic import BaseModel +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_analyze_files_tool, +) + + +class Request(BaseModel): + document: UiPathAttachment # the platform's Attachment model + question: str + + +class Output(BaseModel): + answer: str + source: UiPathAttachment | None = None + + +agent = UiPathCodexAgent( + input_schema=Request, + output_schema=Output, + prompt="Answer {question} about the attached document.", + tools=[uipath_analyze_files_tool()], +) +``` + +* Schemas: a `UiPathAttachment` field is `$ref: #/definitions/job-attachment` + (the `JobAttachment` resource kind) in the entrypoint's input and output + schemas, as in LangChain, so Orchestrator shows a file picker. +* The prompt: an attachment renders as JSON, `{"ID", "FullName", + "MimeType"}` (`{document}`, or `{document[FullName]}`). Attachments the + template does not show are listed in a block after it. A short developer + instruction tells the model how to use them. +* Known attachments: those of the input, and those tools return. A tool + argument at an attachment field (`$ref` to `job-attachment`, the + `JobAttachment` marker, or a `UiPathAttachment` model field) may be just + `{"ID": ...}`; the tool host fills in the rest before the tool runs. An ID + the run does not know is answered with LangChain's "Could not find + JobAttachment with ID=..." error, and the tool does not run. As in + LangChain, the IDs checked are those of the arguments with the tool's + pre-filled values merged in (see "Pre-filled arguments" above): a value + the agent input or a static value replaces is not checked, the + pre-filled one is. A custom tool with pre-filled fields gets the same + through two optional members, `prefilled_args(args, agent_input)` (the + merged arguments) and `prefilled_schema` (the schema they follow), and + `checks_prefilled_attachments = False` to have only the model's own + values the merge keeps checked (as an escalation does). The + attachments at the attachment fields of a tool's output schema (e.g. a + process tool's `output_schema`) become known. They are saved with a + suspended run. +* Process and agent tools link the attachments in their arguments to the + child job (`processes.invoke_async(attachments=...)`). +* Output: the model writes an attachment field as `{"ID": ...}` (the strict + output schema asks for nothing else) and the runtime fills in the rest. An + ID the run does not know fails the run with `OUTPUT_INVALID`. Files the + agent writes are not uploaded. +* `uipath_analyze_files_tool()` (or `analyze_files_tool_from_resource(...)` + for an `agent.json` internal tool with `toolType` `analyze-attachments`, + including its `argumentProperties`): the model passes `attachments` and an + `analysisTask` (an `argument` `argumentProperties` reads them from the + agent input; the model's are used when it has none). The tool downloads + each file, sends it with the task to the agent's model (or `model=`) in + one LLM Gateway call, with LangChain's system message, and answers `{"analysisResult": ...}`. Images (PNG, JPEG, + GIF, WebP) go as images, other files (PDF, ...) as files; TIFF is not + supported yet. A file over 30 MB fails the run (`FILE_ERROR`), as in + LangChain. +* Tracing: `attachments` (LLMOps' `SpanAttachment` list) on `Agent run` + (the input's), `Agent output` (the output's) and each `Tool call` (its + arguments' and result's; the arguments as merged with the pre-filled + values, so an attachment taken from the agent input is listed too). Analyze Files nests like low-code's internal + tools: `Tool call - Analyze_Files` (`toolType` `Internal`) → `Analyze + Files` → `LLM call` with the files sent. +* Testing locally: upload a file with `UiPath().attachments.upload(...)` and + pass its `{ID, FullName, MimeType}` in the input, as + [`samples/analyze-files-agent`](samples/analyze-files-agent)'s `upload.py` + does. + +* DeepRAG on an attachment: see "DeepRAG on a job attachment" under + "Context grounding tools". + +* Batch Transform on an attachment: see "Batch Transform on a job + attachment" under "Context grounding tools". + +Not supported yet: conversational attachments, attachments in Integration Service file +uploads, and TIFF files in Analyze Files. + +## Tracing + +A job is one span tree with one root, however many times it suspends. It +uses the same span types as low-code agents: + +* `Agent run - ` (`agentRun`): prompt, input, schemas, output, + and `status` (successful or faulted). It opens in the first execution and + closes in the last one. + * `LLM call` (`llmCall`): one per model request, with the output text, tool + calls and token usage. The shim sends the trace context to the LLM + Gateway, which adds its `Model run` span underneath. + * `Tool call - ` (`toolCall`): MCP tools, shell commands, file edits + and web searches that Codex runs, plus durable tool calls. A durable + call's span opens when its job starts and stays open until a resumed + execution reads the job's result. The span then closes with that result + as its output. It is the child job's `ParentSpanId`, so the child job's + trace sits under it. For process, agent and escalation tools its `input` + is the arguments with the pre-filled fields merged in, as in low-code. + An Integration Service call is a `Tool call` with + `toolType` `Integration` and an `integrationTool` child span (`toolName`, + `arguments`, `result`). The SDK's own `activity_invoke` spans sit under + that child. + * `Agent output` (`agentOutput`): on success. + +When an execution suspends, it does not end the `Agent run` span or the +`Tool call` spans of jobs still running. On a deployed job, it sends them to +LLMOps as still running. It saves their identity (trace id, span id, start +time, attributes) in the state database. Each resumed execution parents its +spans (resume-trigger reads, LLM calls, tool calls) directly under the saved +`Agent run`, as low-code agents do. The execution that finishes the job sends +the `Agent run` with its original id and start time and the final status and +output. The span goes through every span processor the CLI set up, so +LLMOps and a local `--trace-file` both get it. With `--trace-file`, a span +appears only when it ends. A file shared across `uipath run` and +`uipath run --resume` therefore holds the complete tree once the job ends. +If the job faults, the `Agent run` and any durable `Tool call` that is still +open close with an error. + +A fresh (non-resume) run discards any saved trace context. When a job is +resumed in the same process (its job had already finished at suspend time), +everything stays inside one live `Agent run`. Agents +without durable tools never suspend, so each run is one `Agent run`. + +## Evaluations + +Codex agents run under `uipath eval` like any other coded agent. Eval sets +live in `evaluations/eval-sets/`, and the evaluators they reference live in +`evaluations/evaluators/`. Run one from the agent's directory: + +```bash +uipath eval agent evaluations/eval-sets/evaluation-set-default.json +``` + +Two samples ship with eval sets: + +* [`samples/structured-output-agent`](samples/structured-output-agent): three + topics, each scored with `uipath-json-similarity` against a reference joke + and with `uipath-contains` on the topic word. Jokes vary between runs, so + there is no exact-match evaluator: no field of the output is deterministic. +* [`samples/local-mcp-agent`](samples/local-mcp-agent): the three orders known + to `orders_server.py` (`A-1001` shipped, `A-1002` processing, `A-1003` + delivered). Each case checks that `get_order_status` was called once, with + the requested `order_id`, and that the reply contains a detail only the tool + returns. + +```bash +cd samples/local-mcp-agent +uv sync +uv run uipath auth +uv run uipath eval agent evaluations/eval-sets/evaluation-set-default.json +``` + +### Tool names in tool-call evaluators + +The tool-call evaluators (`uipath-tool-call-order`, `-count`, `-args`, +`-output`) match the tool spans the runtime emits. Use these names: + +| Tool | Name in the evaluator | +| --- | --- | +| Shell command | `shell` | +| File edit | `apply_patch` | +| Web search | `web_search` | +| MCP tool | the plain tool name, e.g. `get_order_status` (the server is in the span's `tool.server` attribute, not in the name) | +| Durable process or agent tool | the name the model sees, e.g. `a_plus_b` | +| Integration Service tool | the name the model sees, e.g. `web_search` | +| Context grounding tool | the name the model sees, e.g. `Company_Policies` | +| Escalation tool | the name the model sees, e.g. `escalate_ExpenseApproval` | +| Analyze Files | the name the model sees, e.g. `Analyze_Files` | +| DeepRAG on an attachment | the name the model sees, e.g. `DeepRAG` | +| Batch Transform on an attachment | the name the model sees, e.g. `Batch_Transform` | +| IXP extraction / Document Validation | the name the model sees, e.g. `Extract_Invoice` | + +A tool span's output is `{"content": ""}`. The +`uipath-tool-call-output` evaluator unwraps it, so a criterion's `output` is +the plain text the tool returned, for example +`{"name": "get_order_status", "output": "Shipped, arriving 2026-09-10 via the Bucharest hub."}`. + +### Durable tools in evals + +Mocked durable tools answer in the same turn and never suspend; only the +unmocked ones need the resume steps below. A case that calls an unmocked +durable tool starts a real job (task, extraction, ...) and suspends like +`uipath run` does. Resume works on one +evaluation at a time (`--resume` fails on a set with several evaluations), so +select the case with `--eval-ids`. Resume the eval once the jobs finish, and +pass the **same** `--eval-ids` and `--eval-set-run-id` on both calls: + +```bash +RUN_ID=$(uuidgen) +uipath eval agent evaluations/eval-sets/.json --eval-ids '[""]' --eval-set-run-id "$RUN_ID" +# ... jobs finish ... +uipath eval agent evaluations/eval-sets/.json --eval-ids '[""]' --eval-set-run-id "$RUN_ID" --resume +``` + +Without the same id, the CLI makes a new execution id on the second call and +does not reload the spans recorded before the suspend, so tool-call evaluators +miss the calls made before the suspend. + +### Mocking tools in evals + +The UiPath tools the runtime serves can be simulated in `uipath eval` (and in +`uipath run` / `uipath debug` with a `simulation.json`), with the same +mechanism, names and output schemas as LangChain and low-code agents. A +simulated call starts nothing on the tenant (no job, task, extraction, +search or activity): the mocker's value is the tool's result, in the same +turn, and the run does not suspend on a simulated durable tool. The LLM +strategy still calls the tenant's LLM Gateway to generate the value (see +below); mockito and simulated components need no gateway. + +What is honored (core's own parsing, no Codex-specific schema): + +| Source | Fields | Mocker | +| --- | --- | --- | +| Eval set `version: "1.0"` | `mockingStrategy: {"type": "llm", "prompt", "toolsToSimulate": [{"name"}], "model"?}` | LLM | +| Eval set `version: "1.0"` | `mockingStrategy: {"type": "mockito", "config": [{"function", "arguments"?, "then": [{"type": "return"\|"raise", "value"}]}]}` | mockito | +| Legacy (Studio Web) eval set | `simulateTools`, `simulationInstructions`, `toolsToSimulate` | LLM | +| `simulation.json` / `--simulation` | `{enabled, toolsToSimulate, instructions, model}` or `{components: [...]}` | LLM / simulate-component | + +Names match as the mockers match them: exactly for `toolsToSimulate` and a +mockito `function`; exactly or with `_` read as a space for `components`. +The name a tool is simulated by is LangChain's `resource.name`, not always +the name the model sees (which the tool-call evaluators use, see above): + +| Tool | Simulated as | Mocked value | +| --- | --- | --- | +| Process / agent | the process name passed to the factory, e.g. `a_plus_b`; from a resource, its `name`, e.g. `Create Invoice` (the span is named after `properties.processName`, see Tracing below) | the job output (`output_schema`) | +| Escalation | the model-facing name lowercased, e.g. `escalate_expenseapproval` | `{"action": "approve"\|"reject", "data": {...}, "is_deleted": false}` | +| IXP extraction | the display / resource name, e.g. `Extract Invoice` | an `ExtractionResponseIXP` (`extractionResult`, `projectId`, ..., `dataProjection`) | +| Document Validation | the display / resource name, e.g. `Validate Invoice` | `{"data": [] \| null}` | +| DeepRAG (index) | the display / resource name, e.g. `Invoice Research` | `{"text", "citations", "deepRagId"}` | +| DeepRAG / Batch Transform on an attachment | the name passed to the factory / resource name, e.g. `Batch Transform` | the resource's `outputSchema`, else DeepRAG's; `{"result": }` | +| Integration Service | the display / resource name, e.g. `Get User (v2)` (not `Get_User_v2`) | the activity result (`output_schema`) | +| Context grounding search | the display / resource name, e.g. `Company Policies` | `{"documents": [{"metadata", "page_content"}]}` | +| Analyze Files | the display / resource name, e.g. `Analyze Files` | the resource's `outputSchema`, else `{"analysisResult"}` | +| Your own `UiPathTool` / `UiPathDurableTool` | `mock_name`, else `display_name`, else `name` | `output_json_schema`; a string is the text, anything else JSON | + +A mockito behavior matches keyword arguments, and every one must match: + +| Tool | Keyword arguments the mocker sees | +| --- | --- | +| Escalation, process / agent, DeepRAG / Batch Transform on an attachment | the model's validated arguments with the pre-filled fields (`static_args`, `args_from_input`, `attachment_from_input`, `argumentProperties`) merged in, as in LangChain; a required agent-input field neither the agent input nor the model filled is a tool error | +| IXP, Document Validation, DeepRAG on an index, your own tools | the model's validated arguments | +| Context grounding search | `{"query": ...}`: the query searched for (the agent input's when it is set from it); `{}` for a static query | +| Integration Service | the activity input: the model's arguments with the static and agent-input values merged in | +| Analyze Files | the model's arguments with the pre-filled values merged in; a required agent-input field neither the agent input nor the model filled is a tool error | + +An attachment the model passes as `{"ID": ...}` is the full JobAttachment +`{"ID", "FullName", "MimeType"}` by then, as in LangChain. + +For example: + +```json +{ + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "a_plus_b", + "arguments": {"args": [], "kwargs": {"a": 2, "b": 3}}, + "then": [{"type": "return", "value": {"sum": 5}}] + } + ] + } +} +``` + +* A tool with no behavior (or not in `toolsToSimulate`) runs for real. Mockito + arguments no behavior matches, a `raise` answer, or a failed LLM + simulation come back to the model as a tool error. +* The mocked value goes through the tool's own result handling: an escalation + `outcome_mapping` to `end`, or `is_deleted`, ends the run + (`TERMINATION_ESCALATION_REJECTED`); a simulated IXP extraction is kept for + the validation tool; attachments in a simulated result become known to the + run. +* `uipath_process_tool(..., example_calls=[...])` / + `uipath_agent_tool(..., example_calls=[...])` give the LLM mocker examples; + tools from an `agent.json` use the resource's `exampleCalls` (Integration + Service, IXP and escalation channels). +* Tracing, as low-code: `Tool call - ` keeps its output; the tool's + child span (`processTool` / `agentTool`, `escalationTool`, `ixpTool`, + `vsEscalationTool`, `contextGroundingTool`, `integrationTool`, the internal + tool span) keeps its type, gets `__uipath_response_mocked: true` and is + renamed `Simulated result: `. A custom tool has no child span: the + attribute sits on its `Tool call` span, which is never renamed. A process + or agent tool from a resource names its child after + `properties.processName` (`Simulated result: CreateInvoice`; the mocker's + function name is the resource `name`, `Create Invoice`). A Flow resource + has no `processTool` child, as in low-code: the attribute sits on its + `Tool call` span, as for a custom tool. +* The LLM mocker calls the LLM Gateway over chat completions with + `mockingStrategy.model`, else the agent's model (`metadata.settings.model` + of the schema). If the agent's model only answers on the Responses API, set + `mockingStrategy.model` to one that answers chat completions. +* The LLM mocker answers in the tool's output schema. Give tools you declare + in code an `output_schema` (process, agent and Integration Service tools): + without one the schema is an empty object, and the mocker may return `{}` + or copy the `{"content": ...}` shape of earlier tool spans. Tools from an + `agent.json` use the resource's `outputSchema`. +* Cannot be mocked: tools of your own MCP servers (`mcp_servers`; the Codex + binary calls them directly, with no Python in between) and Codex built-ins + (shell, `apply_patch`, web search). They run for real in every eval case. + +## Not supported + +* **Conversational agents.** There is no chat runtime yet, so a Codex agent + cannot run as a conversational agent (`uipath dev` chat mode, chat in + Studio Web or Orchestrator). +* **Breakpoints in `uipath debug`.** Codex offers no hook to stop between + steps, so the runtime logs a warning and the run continues to completion. +* **Models other than OpenAI** (see [Models](#models)). +* **Mocking your own MCP servers' tools in evals** (see "Mocking tools in + evals"). +* **Hiding secrets from built-in tool commands** (see + [Built-in tools and the sandbox](#built-in-tools-and-the-sandbox)). diff --git a/packages/uipath-codex-sdk/pyproject.toml b/packages/uipath-codex-sdk/pyproject.toml new file mode 100644 index 00000000..f441b057 --- /dev/null +++ b/packages/uipath-codex-sdk/pyproject.toml @@ -0,0 +1,76 @@ +[project] +name = "uipath-codex-sdk" +version = "0.1.0" +description = "Python SDK that enables developers to build and deploy OpenAI Codex SDK agents to the UiPath Cloud Platform" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "openai-codex>=0.161.0, <0.162.0", + "aiohttp>=3.9.0", + "aiosqlite>=0.19.0", + "httpx>=0.27.0", + "uipath>=2.14.41, <2.15.0", + "uipath-platform>=0.2.34", + "uipath-runtime>=0.13.0, <0.14.0", + "uipath-llm-client[openai]>=1.17.1, <1.19.0", +] +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[project.entry-points."uipath.middlewares"] +register = "uipath_codex_sdk.middlewares:register_middleware" + +[project.entry-points."uipath.runtime.factories"] +codex = "uipath_codex_sdk.runtime:register_runtime_factory" + +[project.urls] +Homepage = "https://uipath.com" +Repository = "https://github.com/UiPath/uipath-integrations-python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "mypy>=1.14.1", + "ruff>=0.9.4", + "pytest>=7.4.0", + "pytest-asyncio>=1.0.0", +] + +[tool.mypy] +plugins = [ + "pydantic.mypy" +] +exclude = [ + "samples/.*", + "template/.*" +] +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = false +check_untyped_defs = true + +[tool.ruff] +line-length = 88 +indent-width = 4 +exclude = ["samples/**", "template/**"] + +[tool.ruff.lint] +select = ["E", "F", "B", "I"] + +[tool.ruff.lint.per-file-ignores] +"*" = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "contract: contract tests against the real bundled codex binary (local mocks, no network)", +] diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/.env.example b/packages/uipath-codex-sdk/samples/analyze-files-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/README.md b/packages/uipath-codex-sdk/samples/analyze-files-agent/README.md new file mode 100644 index 00000000..d1076365 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/README.md @@ -0,0 +1,58 @@ +# Document Q&A (Analyze Files, job attachments) + +A Codex agent that answers a question about an attached document: + +| Input | Tool | Output | +| --- | --- | --- | +| `document` (a job attachment), `question` | `Analyze_Files`: the file and an analysis task go to the agent's model | `{answer, source}`, `source` being the input document | + +As in LangChain agents, the model never opens the file itself: it sees the +attachment as a JobAttachment object (`{"ID", "FullName", "MimeType"}`) and +passes it to the tool. See the main README's "Job attachments" section. + +## Run locally + +```bash +cd samples/analyze-files-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run python upload.py invoice.pdf "What is the total amount due?" # writes input.json +uv run uipath run agent --file input.json +``` + +`upload.py` uploads the file as an Orchestrator attachment and writes its +`{ID, FullName, MimeType}` into `input.json`. Images (PNG, JPEG, GIF, WebP) +and documents such as PDFs work; a file over 30 MB fails the run. + +## Run on Orchestrator + +Publish the agent and start a job: the `document` input is a file picker +(the schema marks it as a `JobAttachment`). + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +The document is not downloaded or sent to a model: `Analyze Files` returns a +fixed `analysisResult`. The input's attachment id is a placeholder, which is +fine while the tool is mocked. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent attachments: the document (in) +├─ LLM call +├─ Tool call - Analyze_Files toolType Internal, attachments: the document (in) +│ └─ Analyze Files input {attachments, analysisTask}, output {analysisResult} +│ ├─ attachments_get_blob_uri +│ └─ LLM call the task and the file, attachments: the document (in) +├─ LLM call +└─ Agent output attachments: the document (out) +``` diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/codex.json b/packages/uipath-codex-sdk/samples/analyze-files-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..9de54fae --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,49 @@ +{ + "version": "1.0", + "id": "analyze-files-agent-mocked", + "name": "Document questions (mocked Analyze Files)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "total-mockito", + "name": "Mockito: a fixed analysis", + "inputs": { + "document": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "invoice.pdf", + "MimeType": "application/pdf" + }, + "question": "What is the total amount due, and by when?" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "1,234.00" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "Analyze_Files" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "Analyze Files", + "then": [ + { + "type": "return", + "value": { + "analysisResult": "The total amount due is 1,234.00 EUR, payable by 2026-12-01." + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/input.json b/packages/uipath-codex-sdk/samples/analyze-files-agent/input.json new file mode 100644 index 00000000..ebecd028 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/input.json @@ -0,0 +1,8 @@ +{ + "document": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "invoice.pdf", + "MimeType": "application/pdf" + }, + "question": "What is the total amount due, and by when?" +} diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/main.py b/packages/uipath-codex-sdk/samples/analyze-files-agent/main.py new file mode 100644 index 00000000..fcc05e82 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/main.py @@ -0,0 +1,42 @@ +"""Analyze Files: the agent answers a question about an attached document. + +The input carries a job attachment (an Orchestrator file). The model sees it +as a JobAttachment object (``ID``, ``FullName``, ``MimeType``) and cannot open +it itself: it hands it to ``Analyze_Files``, which downloads the file and asks +the agent's model about it. The output names the document it answered from; +an output attachment field may only name an attachment the run knows. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_analyze_files_tool, +) + + +class Request(BaseModel): + document: UiPathAttachment = Field(description="The document to read") + question: str = Field(description="What to find out about the document") + + +class Output(BaseModel): + answer: str = Field(description="The answer, from the document's contents") + source: UiPathAttachment | None = Field( + None, description="The document the answer comes from" + ) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Answer this question about the attached document: {question}\n" + "Read the document with Analyze_Files (one call, with a precise " + "analysis task), answer from its result only, and return the document " + "as the source." + ), + tools=[uipath_analyze_files_tool()], +) diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/analyze-files-agent/pyproject.toml new file mode 100644 index 00000000..7e18959e --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-analyze-files-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that answers questions about a job attachment with the Analyze Files tool" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/uipath.json b/packages/uipath-codex-sdk/samples/analyze-files-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/analyze-files-agent/upload.py b/packages/uipath-codex-sdk/samples/analyze-files-agent/upload.py new file mode 100644 index 00000000..2386ae29 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/analyze-files-agent/upload.py @@ -0,0 +1,33 @@ +"""Upload a local file as an Orchestrator attachment and write input.json. + + uv run python upload.py invoice.pdf "What is the total amount due?" + +Uses the folder of the current ``uipath auth`` session (``UIPATH_FOLDER_PATH`` +when set), the same one the Analyze Files tool reads attachments from. +""" + +import json +import mimetypes +import sys +from pathlib import Path + +from dotenv import load_dotenv +from uipath.platform import UiPath + + +def main() -> None: + if len(sys.argv) != 3: + sys.exit(f"usage: {sys.argv[0]} ") + load_dotenv() + path = Path(sys.argv[1]) + key = UiPath().attachments.upload(name=path.name, source_path=str(path)) + mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + document = {"ID": str(key), "FullName": path.name, "MimeType": mime} + Path("input.json").write_text( + json.dumps({"document": document, "question": sys.argv[2]}, indent=2) + "\n" + ) + print(f"Uploaded {path.name} as attachment {key}; wrote input.json") + + +if __name__ == "__main__": + main() diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/.env.example b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/README.md b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/README.md new file mode 100644 index 00000000..0b966cb8 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/README.md @@ -0,0 +1,65 @@ +# Review enrichment (Batch Transform on a job attachment, durable) + +A Codex agent that adds columns to a CSV attachment with Batch Transform: + +| Input | Tool | Output | +| --- | --- | --- | +| `reviews` (a CSV job attachment), `task` | `Enrich_Reviews`: the model passes `attachment` and `query`; columns `sentiment`, `topic` | `{summary, enriched}`, `enriched` being the result CSV | + +The tool is **durable**: the call starts a Batch Transform task on the file +(the service indexes it on the fly) and the run **suspends**. When the task +ends the run resumes: the result is downloaded, uploaded as a job attachment +(`output.csv`, linked to the job) and returned to the model as a +JobAttachment, which the agent puts in its output. A failed task fails the +run. See the main README's "Batch Transform on a job attachment" section. + +## Run locally + +```bash +cd samples/batch-transform-attachments-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run python upload.py reviews.csv "Classify each review by sentiment and topic." +export UIPATH_FOLDER_PATH=Shared # the task needs a folder to run in +uv run uipath run agent --file input.json # suspends on the task +# ... wait a minute or two ... +uv run uipath run agent --resume # resumes; suspends again if still running +``` + +`enriched.ID` in the output is the uploaded result; download it with +`UiPath().attachments.download(key=..., destination_path=...)`. + +## Run on Orchestrator + +Publish the agent and start a job, picking `reviews.csv` in the `reviews` +input. The job suspends while the task runs and Orchestrator resumes it when +the task ends; the result is attached to the job. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +No Batch Transform task is started and the run never suspends: `Enrich +Reviews` returns a `result` attachment, which the agent returns as `enriched`. +The attachment ids are placeholders; the simulated result file does not exist. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent attachments: reviews.csv (in) +├─ LLM call +├─ Tool call - Enrich_Reviews toolType ContextGrounding; attachments: reviews.csv (in), output.csv (out) +│ └─ Enrich_Reviews contextGroundingTool: retrieval_mode BatchTransform, query, output_columns, +│ │ batch_transform_id, results (output.csv); open while suspended +│ └─ contextgrounding_start_batch_transform_from_attachment_async +├─ contextgrounding_retrieve_batch_transform_async, attachments_upload (resume) +├─ LLM call +└─ Agent output attachments: output.csv (out) +``` diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/codex.json b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..ff213148 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,53 @@ +{ + "version": "1.0", + "id": "batch-transform-attachments-agent-mocked", + "name": "Review enrichment (mocked Batch Transform)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "enrich-mockito", + "name": "Mockito: a fixed result file", + "inputs": { + "reviews": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "reviews.csv", + "MimeType": "text/csv" + }, + "task": "Classify each review by sentiment and topic." + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "00000000-0000-0000-0000-000000000001" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "Enrich_Reviews" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "Enrich Reviews", + "then": [ + { + "type": "return", + "value": { + "result": { + "ID": "00000000-0000-0000-0000-000000000001", + "FullName": "reviews-enriched.csv", + "MimeType": "text/csv" + } + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/input.json b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/input.json new file mode 100644 index 00000000..63740a6f --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/input.json @@ -0,0 +1,8 @@ +{ + "reviews": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "reviews.csv", + "MimeType": "text/csv" + }, + "task": "Classify each review by sentiment and topic." +} diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/main.py b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/main.py new file mode 100644 index 00000000..49c52a02 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/main.py @@ -0,0 +1,52 @@ +"""Batch Transform on a job attachment: the agent enriches a CSV of reviews. + +The input carries a CSV job attachment. The model passes it to +``Enrich_Reviews``, a *durable* tool: the call starts a Batch Transform task +that adds ``sentiment`` and ``topic`` columns to every row, and the run +suspends. When the task ends the run resumes, the result CSV is uploaded as a +job attachment (``output.csv``) and the agent returns it. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_batch_transform_attachments_tool, +) + + +class Request(BaseModel): + reviews: UiPathAttachment = Field(description="CSV file of customer reviews") + task: str = Field(description="What to do with the reviews") + + +class Output(BaseModel): + summary: str = Field(description="What the transform did") + enriched: UiPathAttachment = Field(description="The CSV with the new columns") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "{task}\n" + "Enrich the attached reviews with Enrich_Reviews (call it once, with a " + "clear instruction for every row). Return the file it produces as " + "'enriched' and summarize what was added." + ), + tools=[ + uipath_batch_transform_attachments_tool( + "Enrich Reviews", + description=( + "Adds a sentiment and a topic column to every row of a CSV of " + "customer reviews and returns the new CSV. Takes a few minutes." + ), + output_columns=[ + ("sentiment", "positive, neutral or negative"), + ("topic", "the product area the review is about, in 1-3 words"), + ], + ) + ], +) diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/pyproject.toml new file mode 100644 index 00000000..18f77fc3 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-batch-transform-attachments-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that enriches a CSV job attachment with Batch Transform (durable)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/reviews.csv b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/reviews.csv new file mode 100644 index 00000000..3d4b193d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/reviews.csv @@ -0,0 +1,6 @@ +id,review +1,"The battery lasts two full days, I love it." +2,"Stopped charging after a week. Support never answered." +3,"Decent screen, but the speakers are tinny." +4,"Setup took five minutes and the app is clear." +5,"Arrived with a cracked case; the replacement was fine." diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/uipath.json b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/upload.py b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/upload.py new file mode 100644 index 00000000..6f76d55a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/batch-transform-attachments-agent/upload.py @@ -0,0 +1,33 @@ +"""Upload a local file as an Orchestrator attachment and write input.json. + + uv run python upload.py reviews.csv "Classify each review" + +Uses the folder of the current ``uipath auth`` session (``UIPATH_FOLDER_PATH`` +when set), the same one the agent reads attachments from. +""" + +import json +import mimetypes +import sys +from pathlib import Path + +from dotenv import load_dotenv +from uipath.platform import UiPath + + +def main() -> None: + if len(sys.argv) != 3: + sys.exit(f"usage: {sys.argv[0]} ") + load_dotenv() + path = Path(sys.argv[1]) + key = UiPath().attachments.upload(name=path.name, source_path=str(path)) + mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + document = {"ID": str(key), "FullName": path.name, "MimeType": mime} + Path("input.json").write_text( + json.dumps({"reviews": document, "task": sys.argv[2]}, indent=2) + "\n" + ) + print(f"Uploaded {path.name} as attachment {key}; wrote input.json") + + +if __name__ == "__main__": + main() diff --git a/packages/uipath-codex-sdk/samples/coding-agent/.gitignore b/packages/uipath-codex-sdk/samples/coding-agent/.gitignore new file mode 100644 index 00000000..e9ed58f7 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/.gitignore @@ -0,0 +1 @@ +workspace/ diff --git a/packages/uipath-codex-sdk/samples/coding-agent/README.md b/packages/uipath-codex-sdk/samples/coding-agent/README.md new file mode 100644 index 00000000..7b09e3df --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/README.md @@ -0,0 +1,65 @@ +# Coding agent (built-in tools) + +A Codex agent that writes a file and runs it with Codex's own tools (file +edits and shell commands); no UiPath tools are involved. + +```python +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="{task}", + input_schema=CodingTask, + sandbox="full-access", + approval_mode="deny_all", + base_instructions=( + "You are a coding assistant. Keep every file you create in the " + "./workspace folder (create it if needed). Create and run code as " + "needed, then report what you did and the result." + ), +) +``` + +`sandbox="full-access"` lets the built-in tools write files and run commands. +It is the only mode that works on a serverless robot, which does not have the +Linux features Codex's `"workspace-write"` sandbox needs. On a robot each job +runs in its own working directory, and the files it writes are discarded with +the job. Locally the agent writes into `./workspace` (git-ignored). + +> **Security:** commands the agent runs inherit the job's environment, +> including `UIPATH_ACCESS_TOKEN`. Only give an agent `"full-access"` if you +> trust its inputs. + +## Run locally + +```bash +cd samples/coding-agent +uv sync +uv run uipath auth +uv run uipath run agent --file input.json +``` + +`input.json` asks for `workspace/primes.py` printing the first 10 primes, run +with `python3`; the output reports the file and its exact output +(`2 3 5 7 11 13 17 19 23 29`). + +## Run on Orchestrator + +```bash +uv run uipath pack +uv run uipath publish --my-workspace +``` + +Start a job with `input.json`. The robot's Python runs the generated script. + +## Trace + +``` +Agent run - agent +├─ LLM call +├─ Tool call - shell toolType Internal, input {"command": "...cat > workspace/primes.py ... && python3 primes.py"} +├─ LLM call +└─ Agent output +``` + +The model decides how to split the work: one shell command that writes and +runs the file, or a `Tool call - apply_patch` (the file change) followed by a +`Tool call - shell`. diff --git a/packages/uipath-codex-sdk/samples/coding-agent/codex.json b/packages/uipath-codex-sdk/samples/coding-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/coding-agent/input.json b/packages/uipath-codex-sdk/samples/coding-agent/input.json new file mode 100644 index 00000000..fc88f0d8 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/input.json @@ -0,0 +1,3 @@ +{ + "task": "Create a file workspace/primes.py that prints the first 10 prime numbers, run it with python3 from the workspace folder, and report its exact output." +} diff --git a/packages/uipath-codex-sdk/samples/coding-agent/main.py b/packages/uipath-codex-sdk/samples/coding-agent/main.py new file mode 100644 index 00000000..3e4d7fde --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/main.py @@ -0,0 +1,23 @@ +"""Codex coding agent: uses built-in shell + file-edit tools in a workspace.""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent + + +class CodingTask(BaseModel): + task: str = Field(description="The coding task for Codex to carry out.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="{task}", + input_schema=CodingTask, + sandbox="full-access", + approval_mode="deny_all", + base_instructions=( + "You are a coding assistant. Keep every file you create in the " + "./workspace folder (create it if needed). Create and run code as " + "needed, then report what you did and the result." + ), +) diff --git a/packages/uipath-codex-sdk/samples/coding-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/coding-agent/pyproject.toml new file mode 100644 index 00000000..e1f5086f --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-coding-agent" +version = "0.0.1" +description = "UiPath Codex SDK coding sample (built-in shell + file tools)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/coding-agent/uipath.json b/packages/uipath-codex-sdk/samples/coding-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/coding-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/.env.example b/packages/uipath-codex-sdk/samples/context-grounding-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/README.md b/packages/uipath-codex-sdk/samples/context-grounding-agent/README.md new file mode 100644 index 00000000..7df35660 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/README.md @@ -0,0 +1,69 @@ +# Invoice questions (Context Grounding tool) + +A Codex agent that answers questions about invoices from a Context Grounding +index, through one context tool: + +| Tool | Index | Model provides | Returns | +| --- | --- | --- | --- | +| `search_invoices` | `invoices` (folder `Coded`), semantic search, 5 results | `query` | `{"documents": [{"metadata": {source, search_id, reference, page_number, score}, "page_content": ...}]}` | + +The tool is *immediate*: the call is answered when the search returns and the +run never suspends. See the main README's "Context grounding tools" section +for the query variants (fixed, from the agent input) and the result shape. + +## Prerequisites + +* A Context Grounding index with some documents, ingested. This sample uses + an index named `invoices` in the `Coded` folder, on a storage bucket with an + invoice. For another index, change `INDEX_NAME` / `INDEX_FOLDER` in + `main.py` and the binding in `bindings.json` (its key is + `.`). +* Read access to the index's folder for whoever runs the agent. + +```bash +cd samples/context-grounding-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json +``` + +The output is structured: `{answer, invoice_numbers, sources}`. For example, +for "How much does Acme Corp owe us, and by when?": `{"answer": "Acme Corp +owes $4,200, due by 2026-03-15.", "invoice_numbers": ["INV-7421"], "sources": +["invoice.jpg"]}`. + +## Rebinding the index + +`bindings.json` declares the index as a bindable `index` resource. When the +agent is deployed, Orchestrator can point the binding at another index or +folder. The tool passes the index name and folder through the SDK's resource +override on every call, so no code change is needed. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +The index is not searched: `Search Invoices` (the tool's name, not the +model-facing `search_invoices`) is answered by an LLM in the `{"documents": +[...]}` shape of a real search. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent +├─ LLM call +├─ Tool call - search_invoices toolType ContextGrounding, index_name, context_retrieval_mode +│ └─ Search Invoices contextGroundingTool: retrieval_mode, query, number_of_results, output (documents) +│ └─ ContextGroundingRetriever RETRIEVER: input.value (query), output.value (documents), read by the evaluators +│ ├─ folder_retrieve_key (when the index is not in the job's folder) +│ └─ contextgrounding_unified_search → contextgrounding_retrieve +├─ LLM call +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/bindings.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/bindings.json new file mode 100644 index 00000000..9620b4b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/bindings.json @@ -0,0 +1,26 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "index", + "key": "invoices.Coded", + "value": { + "name": { + "defaultValue": "invoices", + "isExpression": false, + "displayName": "Name" + }, + "folderPath": { + "defaultValue": "Coded", + "isExpression": false, + "displayName": "Folder Path" + } + }, + "metadata": { + "ActivityName": "unified_search_async", + "BindingsVersion": "2.2", + "DisplayLabel": "FullName" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/codex.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..d43f27e0 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,37 @@ +{ + "version": "1.0", + "id": "context-grounding-agent-mocked", + "name": "Invoice questions (mocked search)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "acme-llm", + "name": "LLM: one matching invoice", + "inputs": { + "question": "How much does Acme Corp owe us, and by when?" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "INV-2026-0417" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "search_invoices" + ] + } + }, + "mockingStrategy": { + "type": "llm", + "prompt": "Return one document: invoice INV-2026-0417 from Acme Corp, total 12,480.00 USD, due 2026-11-15, source acme-inv-2026-0417.pdf.", + "toolsToSimulate": [ + { + "name": "Search Invoices" + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/input.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/input.json new file mode 100644 index 00000000..6b133f85 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/input.json @@ -0,0 +1,3 @@ +{ + "question": "How much does Acme Corp owe us, and by when?" +} diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/main.py b/packages/uipath-codex-sdk/samples/context-grounding-agent/main.py new file mode 100644 index 00000000..17eb2929 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/main.py @@ -0,0 +1,56 @@ +"""Context grounding tool: the agent answers questions from an invoices index. + +``search_invoices`` runs a semantic search on the Context Grounding index +``invoices`` (folder ``Coded``), an *immediate* tool (the run never suspends +on it). The model writes the query; the tool returns the best matching chunks +as ``{"documents": [{"metadata": {...}, "page_content": "..."}]}``. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_context_tool + +#: The Context Grounding index and its folder. They are also the key of the +#: index binding in bindings.json, so a deployment can rebind them. +INDEX_NAME = "invoices" +INDEX_FOLDER = "Coded" + + +class Request(BaseModel): + question: str = Field(description="Question about the invoices") + + +class Output(BaseModel): + answer: str = Field(description="Answer, based only on the invoices found") + invoice_numbers: list[str] = Field( + description="Numbers of the invoices the answer is based on" + ) + sources: list[str] = Field(description="Source documents of those invoices") + + +search_invoices = uipath_context_tool( + "Search Invoices", + tool_name="search_invoices", + index_name=INDEX_NAME, + folder_path=INDEX_FOLDER, + description=( + "Searches the company's invoices (invoice number, customer, total " + "amount, due date). Returns the most relevant invoice passages." + ), + number_of_results=5, +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Answer this question about our invoices: {question}\n" + "Look the invoices up with search_invoices and answer only from what " + "it returns. Report the invoice numbers and the source documents of " + "the invoices your answer is based on. If nothing relevant is found, " + "say so and leave both lists empty." + ), + tools=[search_invoices], +) diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/context-grounding-agent/pyproject.toml new file mode 100644 index 00000000..d9564e32 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-context-grounding-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that answers questions from a Context Grounding index" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/context-grounding-agent/uipath.json b/packages/uipath-codex-sdk/samples/context-grounding-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/context-grounding-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/.env.example b/packages/uipath-codex-sdk/samples/deep-rag-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/README.md b/packages/uipath-codex-sdk/samples/deep-rag-agent/README.md new file mode 100644 index 00000000..ca05852c --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/README.md @@ -0,0 +1,69 @@ +# Finance research (DeepRAG tool, durable) + +A Codex agent that answers finance research questions with a DeepRAG task on +a Context Grounding index: + +| Tool | Index | Model provides | Returns | +| --- | --- | --- | --- | +| `research_finance` | `finance-resources` (folder `Coded`), citation mode `Inline` | `query` (the research task) | `{"text": ..., "citations": [{ordinal, page_number, source, reference}], "deepRagId": ...}` | + +The tool is **durable**: the call starts the DeepRAG task and the run +**suspends**. The run resumes when the task ends; a failed task comes back to +the model as `{"status": "failed", "error": ...}`. See the main README's +"DeepRAG (durable)" section. + +## Prerequisites + +* A Context Grounding index with PDF or TXT documents, ingested (DeepRAG + refuses an index without them: "No valid files to use for Summarization"). + This sample uses the `finance-resources` index in the `Coded` folder, with + finance PDFs such as the Federal Reserve's "The Fed Explained". For another + index, + change `INDEX_NAME` / `INDEX_FOLDER` in `main.py` and the binding in + `bindings.json` (key `.`). +* Read access to the index's folder for whoever runs the agent. + +## Run locally + +```bash +cd samples/deep-rag-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json # suspends on the DeepRAG task +# ... wait a minute or two for the task to finish ... +uv run uipath run agent --resume # resumes; suspends again if it is still running +``` + +The output is structured: `{answer, sources}`. + +## Run on Orchestrator + +Publish the agent and start a job: the job suspends while the task runs, and +Orchestrator resumes it when the task ends (the DeepRAG resume trigger). + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +No DeepRAG task is started and the run never suspends: `Research Finance +Resources` (the tool's name) returns a fixed `{text, citations, deepRagId}`. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent +├─ LLM call +├─ Tool call - research_finance toolType ContextGrounding, open while the run is suspended +│ └─ Research Finance Resources contextGroundingTool: retrieval_mode DeepRAG, query, citation_mode, deep_rag_id, output +│ └─ folder_retrieve_key, contextgrounding_start_deep_rag_async → contextgrounding_retrieve +├─ contextgrounding_retrieve_deep_rag_async (resume: reading the task) +├─ LLM call +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/bindings.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/bindings.json new file mode 100644 index 00000000..7e1be759 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/bindings.json @@ -0,0 +1,26 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "index", + "key": "finance-resources.Coded", + "value": { + "name": { + "defaultValue": "finance-resources", + "isExpression": false, + "displayName": "Name" + }, + "folderPath": { + "defaultValue": "Coded", + "isExpression": false, + "displayName": "Folder Path" + } + }, + "metadata": { + "ActivityName": "start_deep_rag_async", + "BindingsVersion": "2.2", + "DisplayLabel": "FullName" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/codex.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..fd9a73ea --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,59 @@ +{ + "version": "1.0", + "id": "deep-rag-agent-mocked", + "name": "Finance research (mocked DeepRAG)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "fed-tools-mockito", + "name": "Mockito: a fixed research answer", + "inputs": { + "question": "What are the Federal Reserve's main monetary policy tools?" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "open market operations" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "research_finance" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "Research Finance Resources", + "then": [ + { + "type": "return", + "value": { + "text": "The Federal Reserve's main tools are open market operations [1], the discount rate [2] and reserve requirements [2].", + "citations": [ + { + "ordinal": 1, + "pageNumber": 3, + "source": "fed-policy-tools.pdf", + "reference": "Open market operations" + }, + { + "ordinal": 2, + "pageNumber": 7, + "source": "fed-policy-tools.pdf", + "reference": "Discount window and reserves" + } + ], + "deepRagId": "00000000-0000-0000-0000-000000000000" + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/input.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/input.json new file mode 100644 index 00000000..28249516 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/input.json @@ -0,0 +1,3 @@ +{ + "question": "What are the Federal Reserve's main monetary policy tools, and how does each one influence interest rates?" +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/main.py b/packages/uipath-codex-sdk/samples/deep-rag-agent/main.py new file mode 100644 index 00000000..67103c59 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/main.py @@ -0,0 +1,55 @@ +"""DeepRAG tool: the agent researches a finance index and cites its sources. + +``research_finance`` runs a DeepRAG task on the Context Grounding index +``finance-resources`` (folder ``Coded``, finance PDFs such as "The Fed +Explained"). It is a *durable* tool: the call starts the +task and suspends the run; when the task ends the run resumes and the model +gets ``{"text": ..., "citations": [...], "deepRagId": ...}``. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_deep_rag_tool + +#: The Context Grounding index and its folder. They are also the key of the +#: index binding in bindings.json, so a deployment can rebind them. +INDEX_NAME = "finance-resources" +INDEX_FOLDER = "Coded" + + +class Request(BaseModel): + question: str = Field(description="Research question about the finance resources") + + +class Output(BaseModel): + answer: str = Field(description="Answer synthesized by DeepRAG, with citations") + sources: list[str] = Field(description="Source documents cited in the answer") + + +research_finance = uipath_deep_rag_tool( + "Research Finance Resources", + tool_name="research_finance", + index_name=INDEX_NAME, + folder_path=INDEX_FOLDER, + citation_mode="Inline", + description=( + "Researches the finance resources (documents about the Federal " + "Reserve, monetary policy, banking supervision and payments) across " + "all documents and returns a synthesized answer with citations. Takes " + "a few minutes." + ), +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Research this question with research_finance: " + "{question}\n" + "Call it once, with a clear research task. Answer from its result " + "only, keep its citations, and list the cited source documents." + ), + tools=[research_finance], +) diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/deep-rag-agent/pyproject.toml new file mode 100644 index 00000000..1cea7795 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-deep-rag-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that researches a Context Grounding index with DeepRAG (durable)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/deep-rag-agent/uipath.json b/packages/uipath-codex-sdk/samples/deep-rag-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/.env.example b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/README.md b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/README.md new file mode 100644 index 00000000..8554d057 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/README.md @@ -0,0 +1,63 @@ +# Document research (DeepRAG on a job attachment, durable) + +A Codex agent that researches an attached document with DeepRAG: + +| Input | Tool | Output | +| --- | --- | --- | +| `document` (a job attachment, PDF or TXT), `question` | `DeepRAG`: the model passes `attachment` and `query` | `{answer, sources}` | + +The tool is **durable**: the call starts a DeepRAG task on the file (the +service builds a temporary index of it, there is no separate indexing step) +and the run **suspends**. It resumes when the task ends; a failed task comes +back to the model as `{"status": "failed", "error": ...}`. See the main +README's "DeepRAG on a job attachment" section. + +## Run locally + +```bash +cd samples/deep-rag-attachments-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run python upload.py report.pdf "What are the main findings?" # writes input.json +export UIPATH_FOLDER_PATH=Shared # DeepRAG needs a folder to run in +uv run uipath run agent --file input.json # suspends on the DeepRAG task +# ... wait a minute or two ... +uv run uipath run agent --resume # resumes; suspends again if still running +``` + +DeepRAG reads PDF and TXT files; another file type is refused when the task +starts, and the model gets the error. + +## Run on Orchestrator + +Publish the agent and start a job, picking the file in the `document` input. +The job suspends while the task runs and Orchestrator resumes it when the +task ends. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +The document is not uploaded and the run never suspends: `DeepRAG` returns a +fixed `{text, citations, deepRagId}`. The input's attachment id is a +placeholder. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent attachments: the document (in) +├─ LLM call +├─ Tool call - DeepRAG toolType Internal, attachments: the document (in); open while suspended +│ └─ DeepRAG the internal tool: input {attachment, query}, deep_rag_id, output +│ └─ contextgrounding_start_deep_rag_from_attachments_async +├─ contextgrounding_retrieve_deep_rag_async (resume: reading the task) +├─ LLM call +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/codex.json b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..d18af3bc --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "id": "deep-rag-attachments-agent-mocked", + "name": "Report findings (mocked DeepRAG on an attachment)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "findings-mockito", + "name": "Mockito: a fixed research answer", + "inputs": { + "document": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "report.pdf", + "MimeType": "application/pdf" + }, + "question": "What are the main findings, and what evidence supports each one?" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "12%" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "DeepRAG" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "DeepRAG", + "then": [ + { + "type": "return", + "value": { + "text": "The report finds that operating costs fell 12% year over year, driven by lower energy prices.", + "citations": [], + "deepRagId": "00000000-0000-0000-0000-000000000000" + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/input.json b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/input.json new file mode 100644 index 00000000..335252e8 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/input.json @@ -0,0 +1,8 @@ +{ + "document": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "report.pdf", + "MimeType": "application/pdf" + }, + "question": "What are the main findings, and what evidence supports each one?" +} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/main.py b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/main.py new file mode 100644 index 00000000..d7c50ab2 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/main.py @@ -0,0 +1,48 @@ +"""DeepRAG on a job attachment: the agent researches an attached document. + +The input carries a job attachment (a PDF or TXT file in Orchestrator). The +model passes it to ``DeepRAG``, a *durable* tool: the call starts a DeepRAG +task on the file (the service indexes it on the fly) and suspends the run; +when the task ends the run resumes and the model gets +``{"text": ..., "citations": [...], "deepRagId": ...}``. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_deep_rag_attachments_tool, +) + + +class Request(BaseModel): + document: UiPathAttachment = Field(description="The document to research (PDF or TXT)") + question: str = Field(description="What to research in the document") + + +class Output(BaseModel): + answer: str = Field(description="Answer synthesized by DeepRAG, with citations") + sources: list[str] = Field(description="Sources cited in the answer") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Research this question in the attached document with DeepRAG: " + "{question}\n" + "Call it once, with a clear research task. Answer from its result " + "only, keep its citations, and list the cited sources." + ), + tools=[ + uipath_deep_rag_attachments_tool( + "DeepRAG", + description=( + "Researches one attached document (up to 1,000 pages) and " + "returns a synthesized answer with citations. Takes a few minutes." + ), + ) + ], +) diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/pyproject.toml new file mode 100644 index 00000000..d749fa1f --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-deep-rag-attachments-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that researches a job attachment with DeepRAG (durable)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/uipath.json b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/upload.py b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/upload.py new file mode 100644 index 00000000..0695da67 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/deep-rag-attachments-agent/upload.py @@ -0,0 +1,33 @@ +"""Upload a local file as an Orchestrator attachment and write input.json. + + uv run python upload.py report.pdf "What are the main findings?" + +Uses the folder of the current ``uipath auth`` session (``UIPATH_FOLDER_PATH`` +when set), the same one the agent reads attachments from. +""" + +import json +import mimetypes +import sys +from pathlib import Path + +from dotenv import load_dotenv +from uipath.platform import UiPath + + +def main() -> None: + if len(sys.argv) != 3: + sys.exit(f"usage: {sys.argv[0]} ") + load_dotenv() + path = Path(sys.argv[1]) + key = UiPath().attachments.upload(name=path.name, source_path=str(path)) + mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + document = {"ID": str(key), "FullName": path.name, "MimeType": mime} + Path("input.json").write_text( + json.dumps({"document": document, "question": sys.argv[2]}, indent=2) + "\n" + ) + print(f"Uploaded {path.name} as attachment {key}; wrote input.json") + + +if __name__ == "__main__": + main() diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/.env.example b/packages/uipath-codex-sdk/samples/durable-process-tool/.env.example new file mode 100644 index 00000000..f5a4a3ef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/.env.example @@ -0,0 +1,8 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= +# Optional, local runs only: the folder the agent "runs in". Orchestrator sets +# it for deployed jobs; durable tools without an explicit folder_path use it. +# UIPATH_FOLDER_PATH= diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/README.md b/packages/uipath-codex-sdk/samples/durable-process-tool/README.md new file mode 100644 index 00000000..46bcb89f --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/README.md @@ -0,0 +1,147 @@ +# Durable process tool + +A Codex agent that calls an Orchestrator **process** as a tool and **suspends** +while the job runs. When the job finishes, the run resumes (possibly on another +robot) and the model receives the job's output as the result of its original +tool call, then finishes the task. + +The process is `a_plus_b`: input arguments `a` and `b` (integers), output `c` +(the sum). The agent asks for two sums in parallel, so one run starts two jobs and +waits for both. + +```python +uipath_process_tool( + "a_plus_b", + folder_path="Coded/a_plus_b", # omit → the folder the agent runs in + description="Adds two integers a and b in a UiPath process.", + input_schema=AddInput, # a: int, b: int +) +``` + +## How it works + +1. The model calls `a_plus_b`. The runtime serves durable tools to Codex from + its own MCP endpoint (`uipath_tools`), so the call reaches the runtime. It + validates the arguments and starts the job with + `processes.invoke_async`. +2. The call is never answered, so the Codex turn stays blocked. When nothing + else is running, the runtime stops Codex, saves the conversation (the Codex + rollout) in `__uipath/state.db`, and the run ends **SUSPENDED** with one + `JOB_RAW` resume trigger per job. +3. On resume, each finished job becomes the result of its call: + * a successful job: its output arguments, as JSON; + * a faulted job: `{"status": "faulted", "error": ..., "job_key": ...}`. The + model sees the error and the run continues; + * a stopped job: `{"status": "stopped", ...}`. + + The runtime then restores the conversation, attaches the results to the + original call ids, and lets the model continue. +4. If a job is still running on resume, the run suspends again on it. + +## Prerequisites + +* An `a_plus_b` process deployed in an Orchestrator folder: input arguments + `a`, `b` (Int32) and an output argument `c` (Int32) with the sum. If yours + is named differently, change `AddOutput` in `main.py` to match. +* `uipath auth` against the tenant. Set `folder_path` in `main.py` (and + `folderPath` in `bindings.json`) to the folder of your `a_plus_b` release. + How the folder is chosen, like the other UiPath integrations: + 1. `folder_path` on the tool, when given; + 2. otherwise the folder the agent runs in (`UIPATH_FOLDER_PATH`, set by + Orchestrator for a deployed job; set it in `.env` only for local runs); + 3. at deploy time, a resource overwrite on the `bindings.json` entry can + point the tool at another folder. + +```bash +cd samples/durable-process-tool +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +# .env is written by `uipath auth`; nothing else is required +``` + +## Local run against a real tenant + +```bash +# 1. Run: starts two a_plus_b jobs and suspends. +uv run uipath run agent --file input.json +``` + +Expect two `a_plus_b` jobs in Orchestrator and a SUSPENDED result. The triggers +are in the state database: + +```bash +sqlite3 __uipath/state.db "select interrupt_id, data from __uipath_resume_triggers" +``` + +There is one row per job: `trigger_name` is `JobRaw`, `item_key` is the child +job key and `interrupt_id` is the model's tool `call_id`. If both jobs finish +while the run is still polling, the run may resume in-process and complete +straight away. That's also a pass. + +```bash +# 2. Once the jobs have finished: resume. The triggers are read from +# state.db, the job outputs are handed to the model, and it reports both sums. +uv run uipath run agent --resume + +# Resuming while a job is still running suspends again on the same jobs. +uv run uipath run agent --resume +``` + +Don't delete `__uipath/` between the run and the resume. A fresh run (without +`--resume`) resets `__uipath/state.db` and `__uipath/codex_home`. + +### Local run without Orchestrator jobs finishing + +You can answer a single pending call by hand. The payload becomes the tool +result: + +```bash +uv run uipath run agent '{"sum": 42}' --resume +``` + +With several pending calls, key the payload by call id, e.g. +`'{"call_abc...": {"sum": 42}}'`. The call ids are the `interrupt_id` column. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +No Orchestrator job is started: `a_plus_b` is mocked by its process name. The +mockito case matches each call on its arguments (`{"a": 17, "b": 25}` and +`{"a": 42, "b": 8}`); the LLM case answers from `AddOutput`. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Deployed + +```bash +uv run uipath pack +uv run uipath publish # to the folder that holds a_plus_b +``` + +Start the agent job with `input.json` as input. Expect: + +* the parent job goes to **Suspended**, with the child `a_plus_b` jobs as its + resume triggers; +* when the children finish, Orchestrator resumes the parent with the same job + key, possibly on another robot; +* the job completes with both sums. + +The traces should link each child job to the parent's span. + +## Notes and limits + +* Only tools declared in `tools=[...]` suspend. Shell commands and other MCP + servers run as usual. If one of them is called in the same model response as + a durable tool, the suspend waits for it (up to `durable_settle_timeout`, + 120 s by default) and its result is handed back on resume. +* A run waits for **all** pending jobs before the model continues. +* Errors that stop a job from starting (process not found, folder missing, + 409...) are returned to the model as a tool error, not raised. +* The Codex CLI is pinned to 0.161.x: the suspend relies on its rollout and + `thread/inject_items` behaviour. diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/bindings.json b/packages/uipath-codex-sdk/samples/durable-process-tool/bindings.json new file mode 100644 index 00000000..2a49f805 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/bindings.json @@ -0,0 +1,26 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "process", + "key": "a_plus_b.Coded/a_plus_b", + "value": { + "name": { + "defaultValue": "a_plus_b", + "isExpression": false, + "displayName": "Name" + }, + "folderPath": { + "defaultValue": "Coded/a_plus_b", + "isExpression": false, + "displayName": "Folder Path" + } + }, + "metadata": { + "ActivityName": "invoke_async", + "BindingsVersion": "2.2", + "DisplayLabel": "FullName" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/codex.json b/packages/uipath-codex-sdk/samples/durable-process-tool/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..8eb6836a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,101 @@ +{ + "version": "1.0", + "id": "durable-process-tool-mocked", + "name": "Two sums (mocked a_plus_b)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "sums-mockito", + "name": "Mockito: one answer per pair of arguments", + "inputs": { + "x1": 17, + "y1": 25, + "x2": 42, + "y2": 8 + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "50" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "a_plus_b", + "a_plus_b" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "a_plus_b", + "then": [ + { + "type": "return", + "value": { + "c": 42 + } + } + ], + "arguments": { + "kwargs": { + "a": 17, + "b": 25 + } + } + }, + { + "function": "a_plus_b", + "then": [ + { + "type": "return", + "value": { + "c": 50 + } + } + ], + "arguments": { + "kwargs": { + "a": 42, + "b": 8 + } + } + } + ] + } + }, + { + "id": "sums-llm", + "name": "LLM: the simulated process adds", + "inputs": { + "x1": 120, + "y1": 7, + "x2": 300, + "y2": 45 + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "345" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "a_plus_b", + "a_plus_b" + ] + } + }, + "mockingStrategy": { + "type": "llm", + "prompt": "The process returns the correct sum of a and b.", + "toolsToSimulate": [ + { + "name": "a_plus_b" + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/input.json b/packages/uipath-codex-sdk/samples/durable-process-tool/input.json new file mode 100644 index 00000000..0cef23e6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/input.json @@ -0,0 +1,6 @@ +{ + "x1": 17, + "y1": 25, + "x2": 42, + "y2": 8 +} diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/main.py b/packages/uipath-codex-sdk/samples/durable-process-tool/main.py new file mode 100644 index 00000000..e12530df --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/main.py @@ -0,0 +1,54 @@ +"""Durable process tool: the agent calls an Orchestrator process and suspends. + +The model calls ``a_plus_b`` (an Orchestrator process with integer inputs +``a`` and ``b``). Each call starts a job and suspends the run; when the jobs +finish, the run resumes and the model gets each job's output as the result of +its original call. +""" + + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool + + +class Numbers(BaseModel): + x1: int = Field(description="First addend of the first sum") + y1: int = Field(description="Second addend of the first sum") + x2: int = Field(description="First addend of the second sum") + y2: int = Field(description="Second addend of the second sum") + + +class AddInput(BaseModel): + a: int + b: int + + +class AddOutput(BaseModel): + """The job's output arguments. It also shapes a mocked result in evals.""" + + c: int = Field(description="The sum a + b") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Numbers, + prompt=( + "Use the a_plus_b tool to compute {x1} + {y1} and {x2} + {y2}. " + "Make both calls together, in parallel, in a single response. " + "When you have both results, report both sums." + ), + tools=[ + uipath_process_tool( + "a_plus_b", + # Orchestrator folder of the a_plus_b release. Omit it to use the + # folder the agent runs in (Orchestrator sets UIPATH_FOLDER_PATH + # for a deployed job). At deploy time bindings.json lets an + # overwrite point this tool at another folder. + folder_path="Coded/a_plus_b", + description="Adds two integers a and b in a UiPath process.", + input_schema=AddInput, + output_schema=AddOutput, + ) + ], +) diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/pyproject.toml b/packages/uipath-codex-sdk/samples/durable-process-tool/pyproject.toml new file mode 100644 index 00000000..be499bfc --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-durable-process-tool" +version = "0.0.1" +description = "UiPath Codex SDK agent that suspends on an Orchestrator process tool" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/durable-process-tool/uipath.json b/packages/uipath-codex-sdk/samples/durable-process-tool/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/durable-process-tool/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/.env.example b/packages/uipath-codex-sdk/samples/escalation-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/README.md b/packages/uipath-codex-sdk/samples/escalation-agent/README.md new file mode 100644 index 00000000..5645f3e8 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/README.md @@ -0,0 +1,106 @@ +# Expense approval (Action Center escalation, durable) + +A Codex agent that approves small expenses itself and asks a manager for +the rest, through an Action Center app: + +| Tool | App | Model provides | Returns | +| --- | --- | --- | --- | +| `escalate_SimpleApprovalApp` | `SimpleApprovalApp` (folder `Coded/SimpleApprovalAgent`), assigned to `APPROVER` | `Content` (the app's input: the expense) | `{"status": "completed", "outcome": "approve"/"reject", "output": {"Comment": ...}, "task_id": ..., "assigned_to": ...}` | + +The tool is **durable**: the call creates the task and the run **suspends** +until the manager completes it. A `reject` outcome ends the run +(`outcome_mapping={"reject": "end"}`). See the main README's "Action Center +escalations" section. + +## Prerequisites + +* An Action Center app deployed in the tenant, with: + * an input `Content` (text); + * an in/out field `Comment` (text); + * outcome buttons `approve` and `reject`. + + This sample uses `SimpleApprovalApp` in the `Coded/SimpleApprovalAgent` + folder. + + Set `APP_NAME` / `APP_FOLDER` in `main.py` and the binding in + `bindings.json` (key `.`) to your app. +* `APPROVER` in `main.py`: a user (name or email) who can see tasks in that + folder. + +## Run locally + +```bash +cd samples/escalation-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json # suspends on the task +# ... complete the task in Action Center ... +uv run uipath run agent --resume # resumes with the decision +``` + +To answer without Action Center (the real task then stays open): + +```bash +uv run uipath run agent --resume '{"action": "approve", "data": {"Comment": "ok"}}' +``` + +The output is structured: `{approved, decided_by, comment}`. + +## Evaluate with a mocked escalation + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent without +Action Center: the escalation is mocked, so no task is created and the run +never suspends. + +| Case | Mocking | Checks | +| --- | --- | --- | +| Manager approves (mockito) | `escalate_simpleapprovalapp` returns `{"action": "approve", "data": {"Comment": "Budget OK for Q3"}}` | the comment is in the output; the agent escalated | +| Manager approves (LLM) | an LLM writes the manager's answer from the case's `prompt` | the output mentions the conference; the agent escalated | +| Under the limit | the escalation raises, so escalating would fail the case | the agent decided itself; no tool was called | +| Generated input | an LLM writes the agent's input from `inputMockingStrategy.prompt` (a large expense); the escalation is mocked | the manager's comment is in the output; the agent escalated | + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +The mock name is the tool name in lower case (`escalate_simpleapprovalapp`), +as in LangChain. A mocked answer goes through `outcome_mapping` like a real +one: a mocked `reject` ends the run with `TERMINATION_ESCALATION_REJECTED`, +and `uipath eval` does not score a run that ends that way. In the trace, the +`SimpleApprovalApp` span is renamed `Simulated result: SimpleApprovalApp`. +See the main README's "Mocking tools in evals" section. + +## Simulate without an eval + +`uipath run` and `uipath debug` mock tools too, with `--simulation` (or a +`simulation.json` file in the folder, for `uipath debug`): + +```bash +# an LLM writes the manager's answer +uv run uipath run agent --file input.json --simulation '{"enabled": true, "toolsToSimulate": [{"name": "escalate_simpleapprovalapp"}], "instructions": "The manager approves with a short comment."}' + +# the simulate-component API returns a fixed answer (simulationStrategy 2 = static) +uv run uipath run agent --file input.json --simulation '{"enabled": true, "components": [{"componentId": "escalate_simpleapprovalapp", "componentType": "tool", "simulationStrategy": 2, "mockValue": {"action": "approve", "data": {"Comment": "ok"}}}]}' +``` + +No task is created and the run does not suspend. In the `components` format, +mockito rules with a `when` condition do not match yet: the SDK sends the +tool input as `{"args": [...], "kwargs": {...}}` while the API matches +conditions on top-level fields. Rules without `when` work. + +## Run on Orchestrator + +Publish the agent and start a job: the job suspends on the task, and +Orchestrator resumes it when the task is completed. + +## Trace + +``` +Agent run - agent +├─ LLM call +├─ Tool call - escalate_SimpleApprovalApp toolType Escalation, open while the run is suspended +│ └─ SimpleApprovalApp escalationTool: arguments, channelType, taskId, taskUrl, assignedTo, result {outcome, output} +│ └─ tasks_create +├─ LLM call +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/bindings.json b/packages/uipath-codex-sdk/samples/escalation-agent/bindings.json new file mode 100644 index 00000000..6df46eb3 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/bindings.json @@ -0,0 +1,26 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "app", + "key": "SimpleApprovalApp.Coded/SimpleApprovalAgent", + "value": { + "name": { + "defaultValue": "SimpleApprovalApp", + "isExpression": false, + "displayName": "App Name" + }, + "folderPath": { + "defaultValue": "Coded/SimpleApprovalAgent", + "isExpression": false, + "displayName": "App Folder Path" + } + }, + "metadata": { + "ActivityName": "create_async", + "BindingsVersion": "2.2", + "DisplayLabel": "app_name" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/codex.json b/packages/uipath-codex-sdk/samples/escalation-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..aa442889 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,147 @@ +{ + "version": "1.0", + "id": "escalation-agent-mocked", + "name": "Expense approval (mocked escalation)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "manager-approves-mockito", + "name": "Manager approves (mockito)", + "inputs": { + "employee": "Ana Pop", + "amount": 2500, + "reason": "Team offsite in Brasov" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "Budget OK for Q3" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "escalate_SimpleApprovalApp" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "escalate_simpleapprovalapp", + "then": [ + { + "type": "return", + "value": { + "action": "approve", + "data": { + "Comment": "Budget OK for Q3" + } + } + } + ] + } + ] + } + }, + { + "id": "manager-approves-llm", + "name": "Manager approves (LLM)", + "inputs": { + "employee": "Mihai Ionescu", + "amount": 4200, + "reason": "Conference tickets" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "conference" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "escalate_SimpleApprovalApp" + ] + } + }, + "mockingStrategy": { + "type": "llm", + "prompt": "The manager approves the expense, with a short comment that mentions the conference.", + "toolsToSimulate": [ + { + "name": "escalate_simpleapprovalapp" + } + ] + } + }, + { + "id": "under-the-limit", + "name": "Under the limit: no escalation", + "inputs": { + "employee": "Ana Pop", + "amount": 300, + "reason": "Train tickets" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "agent" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "escalate_simpleapprovalapp", + "then": [ + { + "type": "raise", + "value": { + "_target_": "builtins.RuntimeError" + } + } + ] + } + ] + } + }, + { + "id": "generated-input", + "name": "Generated input: large expense", + "inputs": {}, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "Approved for the new hires" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "escalate_SimpleApprovalApp" + ] + } + }, + "inputMockingStrategy": { + "prompt": "An expense from Elena Radu of 5000 EUR for laptops for two new hires." + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "escalate_simpleapprovalapp", + "then": [ + { + "type": "return", + "value": { + "action": "approve", + "data": { + "Comment": "Approved for the new hires" + } + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/input.json b/packages/uipath-codex-sdk/samples/escalation-agent/input.json new file mode 100644 index 00000000..70f130cd --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/input.json @@ -0,0 +1,5 @@ +{ + "employee": "Ana Pop", + "amount": 1250, + "reason": "Team offsite in Brasov" +} diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/main.py b/packages/uipath-codex-sdk/samples/escalation-agent/main.py new file mode 100644 index 00000000..271ee9c1 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/main.py @@ -0,0 +1,77 @@ +"""Action Center escalation: the agent asks a manager to approve an expense. + +``escalate_SimpleApprovalApp`` creates an Action Center task with the +``SimpleApprovalApp`` app and **suspends** the run until a manager completes +it. The run then resumes and the model gets the manager's outcome and +comment. A rejection ends the run (``outcome_mapping={"reject": "end"}``). +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_escalation_tool + +#: The deployed Action Center app and its folder. They are also the key of +#: the app binding in bindings.json, so a deployment can rebind them. +APP_NAME = "SimpleApprovalApp" +APP_FOLDER = "Coded/SimpleApprovalAgent" +#: Who reviews the expenses (a user name or email in the tenant). +APPROVER = "alexandru.groza@uipath.com" +#: Expenses above this amount need a manager. +LIMIT = 1000 + + +class Request(BaseModel): + employee: str = Field(description="Who submitted the expense") + amount: float = Field(description="Amount in EUR") + reason: str = Field(description="What the expense is for") + + +class Output(BaseModel): + approved: bool = Field(description="Whether the expense is approved") + decided_by: str = Field(description="'agent' or the reviewer's email") + comment: str = Field(description="The reviewer's comment, if any") + + +class Review(BaseModel): + """The app's input: what the manager sees.""" + + Content: str = Field( + description="The expense to approve: employee, amount and reason, in one " + "or two sentences." + ) + + +class Decision(BaseModel): + """The app's output: what the manager fills in.""" + + Comment: str = "" + + +approval = uipath_escalation_tool( + APP_NAME, + folder_path=APP_FOLDER, + input_schema=Review, + output_schema=Decision, + title="Expense approval", + assignee=APPROVER, + priority="Medium", + labels=["expenses"], + outcome_mapping={"reject": "end"}, + description=( + "Asks a manager to approve an expense in Action Center and waits for " + "the decision. Use it for every expense above the limit." + ), +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Expense from {employee}: {amount} EUR for '{reason}'.\n" + f"Approve expenses up to {LIMIT} EUR yourself. Above that, ask a " + "manager with escalate_SimpleApprovalApp and report their decision." + ), + tools=[approval], +) diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/escalation-agent/pyproject.toml new file mode 100644 index 00000000..47723921 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-escalation-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that asks a manager to approve expenses through Action Center (durable)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/escalation-agent/uipath.json b/packages/uipath-codex-sdk/samples/escalation-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/escalation-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/.env.example b/packages/uipath-codex-sdk/samples/integration-tool-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/README.md b/packages/uipath-codex-sdk/samples/integration-tool-agent/README.md new file mode 100644 index 00000000..f0bdd922 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/README.md @@ -0,0 +1,128 @@ +# Integration Service tool + +A Codex agent that researches a topic with the **Web Search** activity of the +*UiPath GenAI Activities* connector (`uipath-uipath-airdk`), called as an +Integration Service (IS) tool. + +```python +web_search = uipath_integration_tool( + "web_search", + connection_id=CONNECTION_ID, # also the bindings.json key + connector_key="uipath-uipath-airdk", + tool_path="/v2/webSearch", + method="POST", + input_schema=WebSearchIn, # query, num → JSON body + static_args={"provider": "GoogleCustomSearch"}, # hidden from the model + parameters=[{"name": "X-Elements-Service-Profile", "fieldLocation": "header", + "fieldVariant": "static", "value": "high-capacity"}], +) +``` + +The activity is read-only and has no side effects. + +## How it works + +1. The runtime serves `web_search` to Codex from its own MCP endpoint + (`uipath_tools`), the same one durable tools use. +2. When the model calls it, the runtime validates the arguments, merges the + static values (`provider`, the header), and calls + `UiPath().connections.invoke_activity_async(...)`. That is the same SDK + call low-code agents make. +3. The JSON result goes back to the model as the tool result straight away. + The run never suspends on an IS tool. An IS error (for example a 400 for + a bad argument) goes back as a tool error the model can react to. It + does not fail the run. + +## Prerequisites + +* A **UiPath GenAI Activities** connection in a folder you can use, such as + your personal workspace or `Shared`. Create it once in Integration Service + → Connections → *UiPath GenAI Activities* → Connect, or with + `uip is connections create`. The connector uses first-party auth, so no + third-party sign-in is needed. +* Its id (the GUID in the connection's URL) in `CONNECTION_ID` in `main.py`, + and as `key` and `defaultValue` in `bindings.json`. + +```bash +cd samples/integration-tool-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json +``` + +## Copying a tool from a low-code agent + +A low-code agent's `agent.json` lists its IS tools under `resources` with +`"type": "Integration"`. Such a resource carries the connection, tool path, +parameter locations and schemas. Pass it to `integration_tool_from_resource` +and you get the same tool: + +```python +import json +from uipath_codex_sdk import integration_tool_from_resource + +resources = json.load(open("agent.json"))["resources"] +tools = [ + integration_tool_from_resource(r) + for r in resources + if r.get("type", "").lower() == "integration" and r.get("isEnabled", True) +] +``` + +As in low-code, `static` parameters are fixed and hidden from the model, and +`argument` parameters (`{{name}}`) are read from the agent input (the model +sees them as optional fields and fills them only when the input has no +value). `dynamic` parameters are filled in by the model. The tool name is the +resource name sanitized the way low-code does it, so evaluators and prompts +copied from the low-code agent still match. + +## Rebinding the connection + +`bindings.json` declares the connection as a `connection` resource keyed by +its id. At deploy time a resource overwrite can point the tool at another +connection, for example in another folder or tenant. The runtime always hands +the SDK the id from `main.py`, and the SDK applies the overwrite when it +resolves the connection. No code change is needed. The CLI does not generate +this binding for coded agents, so keep it in sync with `CONNECTION_ID` by +hand. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +No Integration Service call is made: `web_search` is answered by an LLM. The +mocker sees the activity input with the static `provider` merged in, as in +LangChain. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## The trace + +``` +Agent run - agent +├── LLM call +├── Tool call - web_search (toolType Integration, output.value = {"content": }) +│ └── web_search (integrationTool: toolName, arguments, result) +│ └── activity_invoke (the SDK's own spans: connections_retrieve, the HTTP call) +├── LLM call +└── Agent output +``` + +The SDK's `activity_invoke` / `connections_retrieve` spans are not filtered +out (low-code agents hide them). + +## Limitations + +* The input schema comes from you or from the copied resource. It is not + discovered from the connector at runtime. +* File and multipart inputs are passed through as given. A job attachment is + not converted into an upload. +* Every IS error, including a missing connection (404) or missing access + (401/403), goes back to the model as text. Argument errors (400/422) and + timeouts are logged at WARNING, other HTTP errors at ERROR. The run does + not fail. diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/bindings.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/bindings.json new file mode 100644 index 00000000..a1c8b08f --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/bindings.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "43a2061f-18f4-45ae-a411-349b6750c920", + "value": { + "ConnectionId": { + "defaultValue": "43a2061f-18f4-45ae-a411-349b6750c920", + "isExpression": false, + "displayName": "Connection" + } + }, + "metadata": { + "Connector": "uipath-uipath-airdk", + "UseConnectionService": "True", + "BindingsVersion": "2.2" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/codex.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..fcf267e1 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,37 @@ +{ + "version": "1.0", + "id": "integration-tool-agent-mocked", + "name": "Web research (mocked web search)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "search-llm", + "name": "LLM: simulated search results", + "inputs": { + "topic": "the UiPath Codex SDK" + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "docs.example.com" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "web_search" + ] + } + }, + "mockingStrategy": { + "type": "llm", + "prompt": "Return two search results about the topic, both hosted on https://docs.example.com/.", + "toolsToSimulate": [ + { + "name": "web_search" + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/input.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/input.json new file mode 100644 index 00000000..20c5d9e0 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/input.json @@ -0,0 +1,3 @@ +{ + "topic": "the UiPath Codex SDK" +} diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/main.py b/packages/uipath-codex-sdk/samples/integration-tool-agent/main.py new file mode 100644 index 00000000..c252444a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/main.py @@ -0,0 +1,78 @@ +"""Integration Service tool: the agent searches the web through GenAI Activities. + +``web_search`` runs the Integration Service activity **Web Search** of the +"UiPath GenAI Activities" connector (``uipath-uipath-airdk``, +``POST /v2/webSearch``) on your connection. It is an *immediate* tool: each +call is answered as soon as Integration Service returns, and the run never +suspends on it. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool + +#: Id of your "UiPath GenAI Activities" connection (Integration Service → +#: Connections → the connection → its id in the URL). It is also the key of +#: the connection binding in bindings.json, so a deployment can rebind it. +CONNECTION_ID = "43a2061f-18f4-45ae-a411-349b6750c920" + + +class Topic(BaseModel): + topic: str = Field(description="What to research") + + +class WebSearchIn(BaseModel): + query: str = Field(description="Natural-language web search query.") + num: int | None = Field(None, description="Number of results (default 10).") + + +class WebResult(BaseModel): + title: str + url: str + snippet: str | None = None + + +class WebSearchOut(BaseModel): + """The activity's response. It also shapes a mocked result in evals.""" + + formattedResults: str = Field(description="The results, formatted as text") + results: list[WebResult] = Field(description="The results") + + +web_search = uipath_integration_tool( + "web_search", + connection_id=CONNECTION_ID, + connector_key="uipath-uipath-airdk", + object_name="v2::webSearch", + tool_path="/v2/webSearch", + method="POST", + input_schema=WebSearchIn, # sent as the JSON body (POST) + output_schema=WebSearchOut, + # Fixed values, hidden from the model and merged into every call. + static_args={"provider": "GoogleCustomSearch"}, + parameters=[ + # The connector declares this header with a default the SDK does not + # apply by itself. + { + "name": "X-Elements-Service-Profile", + "fieldLocation": "header", + "fieldVariant": "static", + "value": "high-capacity", + } + ], + description=( + "Searches the public web. Returns formatted results plus a list of " + "results with title, url and snippet." + ), +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Topic, + prompt=( + "Research {topic} with the web_search tool (one or two searches are " + "enough) and write a short summary that cites the result URLs." + ), + tools=[web_search], +) diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/integration-tool-agent/pyproject.toml new file mode 100644 index 00000000..fbacad19 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-integration-tool-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that calls an Integration Service activity as a tool" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/integration-tool-agent/uipath.json b/packages/uipath-codex-sdk/samples/integration-tool-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/integration-tool-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/.env.example b/packages/uipath-codex-sdk/samples/ixp-validation-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/README.md b/packages/uipath-codex-sdk/samples/ixp-validation-agent/README.md new file mode 100644 index 00000000..940a7f6a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/README.md @@ -0,0 +1,72 @@ +# Invoice extraction with human validation (IXP + Document Validation, durable) + +A Codex agent that extracts an invoice's fields with IXP and has a person +validate them in Action Center: + +| Step | Tool | The run | +| --- | --- | --- | +| 1 | `Extract_Invoice`: the model passes the invoice attachment | suspends until the IXP extraction ends; the model gets the fields | +| 2 | `Validate_Invoice`: no arguments | creates a Validation Station task for the last extraction and suspends until it is completed; the model gets the validated fields | + +A document marked as an exception in Validation Station ends the run +(`TERMINATION_ESCALATION_REJECTED`), as in low-code agents. See the main +README's "IXP extraction and Document Validation" section. + +## Prerequisites + +* An IXP project with a published model version and its tag (e.g. `live`): + set `PROJECT_NAME` / `VERSION_TAG` in `main.py`. +* An Orchestrator storage bucket: `STORAGE_BUCKET`, in folder `BUCKET_FOLDER` + (the Validation Station task is created in that folder too). +* Someone who can open the task in Action Center. + +## Run locally + +```bash +cd samples/ixp-validation-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run python upload.py invoice.pdf # writes input.json +export UIPATH_FOLDER_PATH=Coded # the folder of the bucket and the task +uv run uipath run agent --file input.json # suspends on the extraction +uv run uipath run agent --resume # once extracted: suspends on the validation task +# ... validate the document in Action Center ... +uv run uipath run agent --resume # the validated fields +``` + +## Run on Orchestrator + +Publish the agent and start a job, picking the invoice in the `invoice` +input. The job suspends twice; Orchestrator resumes it when the extraction +ends and when the task is completed. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with its +UiPath tools mocked, so it needs no tenant resources besides the LLM. + +No extraction or Validation Station task is created and the run never +suspends. `Extract Invoice` returns a full `ExtractionResponseIXP` (kept for +the validation tool, as a real one would be) and `Validate Invoice` returns +the validated fields, with the total corrected from 4,200.00 to 4,250.00 USD. +The input's attachment id is a placeholder. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent attachments: invoice.pdf (in) +├─ LLM call +├─ Tool call - Extract_Invoice toolType IxpExtraction; open while suspended +│ └─ Extract Invoice ixpTool: projectName, versionTag, extractionId, documentId, result +├─ LLM call +├─ Tool call - Validate_Invoice toolType Escalation; open while suspended +│ └─ Validate Invoice vsEscalationTool: ixpToolId, storageBucketName, operationId, taskUrl, result +├─ LLM call +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/codex.json b/packages/uipath-codex-sdk/samples/ixp-validation-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..d782b630 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,169 @@ +{ + "version": "1.0", + "id": "ixp-validation-agent-mocked", + "name": "Invoice extraction (mocked IXP and validation)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "validated-mockito", + "name": "Mockito: extraction, then a corrected total", + "inputs": { + "invoice": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "invoice.pdf", + "MimeType": "application/pdf" + } + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "4,250.00" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "Extract_Invoice", + "Validate_Invoice" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "Extract Invoice", + "then": [ + { + "type": "return", + "value": { + "projectId": "00000000-0000-0000-0000-000000000000", + "projectType": "IXP", + "tag": "staging", + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "extractionResult": { + "DocumentId": "mock-document", + "ResultsVersion": 1, + "ResultsDocument": { + "Bounds": { + "StartPage": 0, + "PageCount": 1, + "TextStartIndex": 0, + "TextLength": 0, + "PageRange": "1" + }, + "Language": "eng", + "DocumentGroup": "", + "DocumentCategory": "", + "DocumentTypeId": "00000000-0000-0000-0000-000000000000", + "DocumentTypeName": "invoices", + "DocumentTypeDataVersion": 0, + "DataVersion": 0, + "DocumentTypeSource": "Automatic", + "DocumentTypeField": { + "Components": [], + "Value": "invoices", + "Unformatted": "invoices", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1, + "OperatorConfirmed": false, + "OcrConfidence": 1, + "TextType": "Unknown" + }, + "Fields": [] + }, + "ExtractorPayloads": null, + "BusinessRulesResults": null + }, + "dataProjection": [ + { + "fieldGroupName": "Invoice", + "fieldValues": [ + { + "id": "vendor-name", + "name": "vendor-name", + "value": "Northwind Traders", + "unformattedValue": "Northwind Traders", + "confidence": 0.98, + "ocrConfidence": 0.99, + "type": "Text" + }, + { + "id": "amount-to-pay", + "name": "amount-to-pay", + "value": "4,200.00 USD", + "unformattedValue": "4,200.00 USD", + "confidence": 0.97, + "ocrConfidence": 0.99, + "type": "Text" + }, + { + "id": "due-date", + "name": "due-date", + "value": "2026-11-30", + "unformattedValue": "2026-11-30", + "confidence": 0.96, + "ocrConfidence": 0.99, + "type": "Text" + } + ] + } + ] + } + } + ] + }, + { + "function": "Validate Invoice", + "then": [ + { + "type": "return", + "value": { + "data": [ + { + "fieldGroupName": "Invoice", + "fieldValues": [ + { + "id": "vendor-name", + "name": "vendor-name", + "value": "Northwind Traders", + "unformattedValue": "Northwind Traders", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + }, + { + "id": "amount-to-pay", + "name": "amount-to-pay", + "value": "4,250.00 USD", + "unformattedValue": "4,250.00 USD", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + }, + { + "id": "due-date", + "name": "due-date", + "value": "2026-11-30", + "unformattedValue": "2026-11-30", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + } + ] + } + ] + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/input.json b/packages/uipath-codex-sdk/samples/ixp-validation-agent/input.json new file mode 100644 index 00000000..326983ca --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/input.json @@ -0,0 +1,7 @@ +{ + "invoice": { + "ID": "00000000-0000-0000-0000-000000000000", + "FullName": "invoice.pdf", + "MimeType": "application/pdf" + } +} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/main.py b/packages/uipath-codex-sdk/samples/ixp-validation-agent/main.py new file mode 100644 index 00000000..8331a1d5 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/main.py @@ -0,0 +1,71 @@ +"""IXP extraction + Document Validation: an invoice agent with a human check. + +The input is an invoice (a job attachment). The agent extracts its fields with +an IXP project (``Extract_Invoice``, durable: the run suspends until the +extraction ends), then sends the extraction to a person in Action Center +(``Validate_Invoice``, a Validation Station task; the run suspends until it is +completed) and returns the validated fields. A document the reviewer marks as +an exception ends the run. + +Set PROJECT_NAME / VERSION_TAG to your IXP project and its published model's +tag, STORAGE_BUCKET to an Orchestrator storage bucket and BUCKET_FOLDER to its +folder. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_document_validation_tool, + uipath_ixp_extraction_tool, +) + +PROJECT_NAME = "invoices" +VERSION_TAG = "staging" +STORAGE_BUCKET = "ixp-escalations" +#: Folder of the bucket (the task is created there too); None means the job's. +BUCKET_FOLDER = "Coded" + + +class Request(BaseModel): + invoice: UiPathAttachment = Field(description="The invoice to process") + + +class Output(BaseModel): + vendor: str | None = Field(None, description="The vendor, as validated") + total: str | None = Field(None, description="The total amount, as validated") + due_date: str | None = Field(None, description="The due date, as validated") + changed: list[str] = Field( + description="Fields the reviewer corrected (empty when none were)" + ) + + +extract = uipath_ixp_extraction_tool( + "Extract Invoice", + project_name=PROJECT_NAME, + version_tag=VERSION_TAG, + description="Extracts the fields of an invoice document. Takes a minute.", +) + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Process the attached invoice: extract its fields with Extract_Invoice, " + "then send the extraction for human validation with Validate_Invoice " + "(only after the extraction has returned). Answer from the validated " + "fields, and list the fields the reviewer changed." + ), + tools=[ + extract, + uipath_document_validation_tool( + "Validate Invoice", + extraction_tool=extract, + storage_bucket_name=STORAGE_BUCKET, + action_folder=BUCKET_FOLDER, + title="Validate the extracted invoice", + ), + ], +) diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/ixp-validation-agent/pyproject.toml new file mode 100644 index 00000000..776e6133 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-ixp-validation-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that extracts invoice fields with IXP and has them validated in Action Center (durable)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/uipath.json b/packages/uipath-codex-sdk/samples/ixp-validation-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/ixp-validation-agent/upload.py b/packages/uipath-codex-sdk/samples/ixp-validation-agent/upload.py new file mode 100644 index 00000000..91376538 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/ixp-validation-agent/upload.py @@ -0,0 +1,33 @@ +"""Upload a local file as an Orchestrator attachment and write input.json. + + uv run python upload.py invoice.pdf + +Uses the folder of the current ``uipath auth`` session (``UIPATH_FOLDER_PATH`` +when set), the same one the agent reads attachments from. +""" + +import json +import mimetypes +import sys +from pathlib import Path + +from dotenv import load_dotenv +from uipath.platform import UiPath + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(f"usage: {sys.argv[0]} ") + load_dotenv() + path = Path(sys.argv[1]) + key = UiPath().attachments.upload(name=path.name, source_path=str(path)) + mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + document = {"ID": str(key), "FullName": path.name, "MimeType": mime} + Path("input.json").write_text( + json.dumps({"invoice": document}, indent=2) + "\n" + ) + print(f"Uploaded {path.name} as attachment {key}; wrote input.json") + + +if __name__ == "__main__": + main() diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/.env.example b/packages/uipath-codex-sdk/samples/jira-issue-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/README.md b/packages/uipath-codex-sdk/samples/jira-issue-agent/README.md new file mode 100644 index 00000000..960c930c --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/README.md @@ -0,0 +1,35 @@ +# Jira issue + comment (Integration Service tools) + +A Codex agent that creates a Jira issue and then comments on it, through two +curated activities of the *Jira* connector (`uipath-atlassian-jira`), called +as Integration Service (IS) tools: + +| Tool | Activity | Model provides | Fixed (hidden from the model) | +| --- | --- | --- | --- | +| `create_jira_issue` | Create Issue — `POST /curated_create_issue` | `fields.summary`, `fields.description` | `fields.project.key = "PC"`, `fields.issuetype.id = "3"` (Task) | +| `add_jira_comment` | Add Comment — `POST /curated_add_comment` | `issueIdOrKey` (query), `body` (comment text) | — | + +The model chains the two calls: it passes the key that Create Issue returns to +Add Comment. Change `PROJECT_KEY` / `TASK_ISSUE_TYPE_ID` in `main.py` for +another project; the issue type ids of a project are listed by the connector +lookup `GET /project//issuetypes`. See +[`integration-tool-agent`](../integration-tool-agent) for how IS tools work +and how to rebind the connection. + +**This sample really creates a Jira issue** (and a comment) in the project. + +## Prerequisites + +* A **Jira** connection with permission to create issues in the project. Put + its id in `CONNECTION_ID` in `main.py` and as `key` / `defaultValue` in + `bindings.json`. +* Access to the connection's folder for whoever runs the agent. + +```bash +cd samples/jira-issue-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json +``` + +The output is structured: `{issue_key, issue_url, comment_id}`. diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/bindings.json b/packages/uipath-codex-sdk/samples/jira-issue-agent/bindings.json new file mode 100644 index 00000000..a61817c1 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/bindings.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "f06551fb-25c2-4a8e-9f48-d55958c12476", + "value": { + "ConnectionId": { + "defaultValue": "f06551fb-25c2-4a8e-9f48-d55958c12476", + "isExpression": false, + "displayName": "Jira connection" + } + }, + "metadata": { + "Connector": "uipath-atlassian-jira", + "UseConnectionService": "True", + "BindingsVersion": "2.2" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/codex.json b/packages/uipath-codex-sdk/samples/jira-issue-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/input.json b/packages/uipath-codex-sdk/samples/jira-issue-agent/input.json new file mode 100644 index 00000000..d2a79605 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/input.json @@ -0,0 +1,5 @@ +{ + "summary": "[Test] Ticket created by a UiPath Codex agent", + "details": "This ticket was created by the uipath-codex-sdk Jira sample (an Integration Service tool) to test creating issues and adding comments. It can be closed.", + "comment": "Follow-up comment added by the same Codex agent right after creating the ticket." +} diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/main.py b/packages/uipath-codex-sdk/samples/jira-issue-agent/main.py new file mode 100644 index 00000000..9562d554 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/main.py @@ -0,0 +1,99 @@ +"""Integration Service tools: the agent creates a Jira issue, then comments on it. + +Two curated activities of the Jira connector (``uipath-atlassian-jira``) on one +connection, both *immediate* tools (the run never suspends on them): + +* ``create_jira_issue`` — **Create Issue** (``POST /curated_create_issue``). + The project and issue type are fixed by this sample; the model writes the + summary and description. +* ``add_jira_comment`` — **Add Comment** (``POST /curated_add_comment``), on + the issue key the create call returned. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool + +#: Id of your Jira connection (Integration Service → Connections → the +#: connection → its id in the URL). It is also the key of the connection +#: binding in bindings.json, so a deployment can rebind it. +CONNECTION_ID = "f06551fb-25c2-4a8e-9f48-d55958c12476" +JIRA = "uipath-atlassian-jira" +#: Jira project key (https://uipath.atlassian.net/jira/software/c/projects/PC). +PROJECT_KEY = "PC" +#: Issue type id of "Task" in that project (GET /project/PC/issuetypes). +TASK_ISSUE_TYPE_ID = "3" + + +class Request(BaseModel): + summary: str = Field(description="Title of the ticket") + details: str = Field(description="What the ticket is about") + comment: str = Field(description="Comment to add once the ticket exists") + + +class Output(BaseModel): + issue_key: str = Field(description="Key of the created issue, e.g. PC-1234") + issue_url: str = Field(description="Browse URL of the created issue") + comment_id: str = Field(description="Id of the added comment") + + +class IssueFields(BaseModel): + summary: str = Field(description="One-line title of the issue.") + description: str = Field(description="Description of the issue (plain text).") + + +class CreateIssueIn(BaseModel): + fields: IssueFields + + +class AddCommentIn(BaseModel): + issueIdOrKey: str = Field(description="Key of the issue to comment on, e.g. PC-1234.") + body: str = Field(description="Text of the comment.") + + +create_issue = uipath_integration_tool( + "create_jira_issue", + connection_id=CONNECTION_ID, + connector_key=JIRA, + object_name="curated_create_issue", + tool_path="/curated_create_issue", + method="POST", + input_schema=CreateIssueIn, # fields.summary / fields.description → JSON body + # Fixed by the sample, hidden from the model, merged into "fields". + parameters=[ + {"name": "fields.project.key", "fieldLocation": "body", "fieldVariant": "static", "value": PROJECT_KEY}, + {"name": "fields.issuetype.id", "fieldLocation": "body", "fieldVariant": "static", "value": TASK_ISSUE_TYPE_ID}, + ], + description=f"Creates a Task in the Jira project {PROJECT_KEY}. Returns the new issue's id and key.", +) + +add_comment = uipath_integration_tool( + "add_jira_comment", + connection_id=CONNECTION_ID, + connector_key=JIRA, + object_name="curated_add_comment", + tool_path="/curated_add_comment", + method="POST", + input_schema=AddCommentIn, + parameters=[ + # The issue key travels in the query string; "body" is the JSON body. + {"name": "issueIdOrKey", "fieldLocation": "query"}, + {"name": "body", "fieldLocation": "body"}, + ], + description="Adds a comment to a Jira issue. Returns the created comment.", +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Create a Jira ticket with create_jira_issue: summary '{summary}', " + "description '{details}'. Then add this comment to the ticket you " + "created with add_jira_comment, using its key: '{comment}'. Report " + "the issue key, its browse URL " + "(https://uipath.atlassian.net/browse/) and the comment id." + ), + tools=[create_issue, add_comment], +) diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/jira-issue-agent/pyproject.toml new file mode 100644 index 00000000..6b135182 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-jira-issue-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that creates a Jira issue and comments on it through Integration Service" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/jira-issue-agent/uipath.json b/packages/uipath-codex-sdk/samples/jira-issue-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/jira-issue-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/README.md b/packages/uipath-codex-sdk/samples/local-mcp-agent/README.md new file mode 100644 index 00000000..5cea7839 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/README.md @@ -0,0 +1,55 @@ +# Local MCP server (stdio) + +A Codex agent that uses a tool from a local MCP server. `orders_server.py` is +a small stdio MCP server with one tool, `get_order_status`, whose answers only +it knows, so a correct report proves the agent called it. + +```python +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Look up the status of order '{order_id}' using the orders tool and report it verbatim.", + input_schema=OrderQuery, + sandbox="full-access", + mcp_servers={ + "orders": StdioMcpServer(command=sys.executable, args=["orders_server.py"]), + }, +) +``` + +Codex starts the server process itself. `sandbox="full-access"` lets it do +that on a robot, where Codex's own sandbox is not available (see the main +README's "Built-in tools and the sandbox"). + +## Run locally + +```bash +cd samples/local-mcp-agent +uv sync +uv run uipath auth +uv run uipath run agent --file input.json # {"order_id": "A-1001"} +``` + +The output reports "Shipped, arriving 2026-09-10 via the Bucharest hub." + +## Evaluate + +`evaluations/eval-sets/evaluation-set-default.json` checks the reported +status and the tool call (count, order and arguments): + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-default.json --no-report +``` + +Tool-call evaluators use the plain tool name, `get_order_status`; the model +sees it as `mcp__orders__get_order_status`. MCP tools are called directly by +Codex, so they cannot be mocked in evals. + +## Trace + +``` +Agent run - agent +├─ LLM call +├─ Tool call - get_order_status tool.server orders +├─ LLM call +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/codex.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/eval-sets/evaluation-set-default.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/eval-sets/evaluation-set-default.json new file mode 100644 index 00000000..ce94556a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/eval-sets/evaluation-set-default.json @@ -0,0 +1,85 @@ +{ + "version": "1.0", + "id": "local-mcp-agent-default", + "name": "Local MCP agent", + "evaluatorRefs": [ + "evaluator-tool-call-order", + "evaluator-tool-call-count", + "evaluator-tool-call-arguments", + "evaluator-contains-status" + ], + "evaluations": [ + { + "id": "order-a-1001", + "name": "Order A-1001 (shipped)", + "inputs": { + "order_id": "A-1001" + }, + "evaluationCriterias": { + "evaluator-tool-call-order": null, + "evaluator-tool-call-count": null, + "evaluator-tool-call-arguments": { + "toolCalls": [ + { + "name": "get_order_status", + "args": { + "order_id": "A-1001" + } + } + ] + }, + "evaluator-contains-status": { + "searchText": "Bucharest hub" + } + } + }, + { + "id": "order-a-1002", + "name": "Order A-1002 (processing)", + "inputs": { + "order_id": "A-1002" + }, + "evaluationCriterias": { + "evaluator-tool-call-order": null, + "evaluator-tool-call-count": null, + "evaluator-tool-call-arguments": { + "toolCalls": [ + { + "name": "get_order_status", + "args": { + "order_id": "A-1002" + } + } + ] + }, + "evaluator-contains-status": { + "searchText": "backordered" + } + } + }, + { + "id": "order-a-1003", + "name": "Order A-1003 (delivered)", + "inputs": { + "order_id": "A-1003" + }, + "evaluationCriterias": { + "evaluator-tool-call-order": null, + "evaluator-tool-call-count": null, + "evaluator-tool-call-arguments": { + "toolCalls": [ + { + "name": "get_order_status", + "args": { + "order_id": "A-1003" + } + } + ] + }, + "evaluator-contains-status": { + "searchText": "J. Popescu" + } + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/contains-status.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/contains-status.json new file mode 100644 index 00000000..d6765f05 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/contains-status.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-contains-status", + "description": "Check that the reply contains a detail only the orders tool knows (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Reports Tool Status", + "case_sensitive": false + } +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-arguments.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-arguments.json new file mode 100644 index 00000000..e0739aa8 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-arguments.json @@ -0,0 +1,21 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-arguments", + "description": "Check that get_order_status was called with the requested order id.", + "evaluatorTypeId": "uipath-tool-call-args", + "evaluatorConfig": { + "name": "Tool Call Arguments", + "strict": false, + "subset": true, + "defaultEvaluationCriteria": { + "toolCalls": [ + { + "name": "get_order_status", + "args": { + "order_id": "A-1001" + } + } + ] + } + } +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-count.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-count.json new file mode 100644 index 00000000..72393418 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-count.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-count", + "description": "Check that the agent looked the order up exactly once.", + "evaluatorTypeId": "uipath-tool-call-count", + "evaluatorConfig": { + "name": "Tool Call Count", + "strict": false, + "defaultEvaluationCriteria": { + "toolCallsCount": { + "get_order_status": ["=", 1] + } + } + } +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..b0eff7c5 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check that the agent called the orders MCP tool. MCP tool spans use the plain tool name.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false, + "defaultEvaluationCriteria": { + "toolCallsOrder": [ + "get_order_status" + ] + } + } +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/input.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/input.json new file mode 100644 index 00000000..fd517fac --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/input.json @@ -0,0 +1,3 @@ +{ + "order_id": "A-1001" +} diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/main.py b/packages/uipath-codex-sdk/samples/local-mcp-agent/main.py new file mode 100644 index 00000000..a280f9de --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/main.py @@ -0,0 +1,33 @@ +"""Codex agent that calls a local stdio MCP server tool.""" + +import os +import sys + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import StdioMcpServer, UiPathCodexAgent + +_HERE = os.path.dirname(os.path.abspath(__file__)) + + +class OrderQuery(BaseModel): + order_id: str = Field(description="The order id to look up, e.g. 'A-1001'.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Look up the status of order '{order_id}' using the orders tool and report it verbatim.", + input_schema=OrderQuery, + sandbox="full-access", + approval_mode="deny_all", + base_instructions=( + "You have an 'orders' MCP tool. Always call it to look up order " + "status; never guess. Report exactly what the tool returns." + ), + mcp_servers={ + "orders": StdioMcpServer( + command=sys.executable, + args=[os.path.join(_HERE, "orders_server.py")], + ) + }, +) diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/orders_server.py b/packages/uipath-codex-sdk/samples/local-mcp-agent/orders_server.py new file mode 100644 index 00000000..4a0d44cc --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/orders_server.py @@ -0,0 +1,25 @@ +"""A tiny stdio MCP server exposing one order-lookup tool. + +The statuses are known only to this tool, so a correct report proves the +agent actually called it (rather than guessing). +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("orders") + +_STATUS = { + "a-1001": "Shipped, arriving 2026-09-10 via the Bucharest hub.", + "a-1002": "Processing, awaiting stock (backordered 3 days).", + "a-1003": "Delivered on 2026-09-05, signed for by J. Popescu.", +} + + +@mcp.tool() +def get_order_status(order_id: str) -> str: + """Return the current fulfillment status for an order id.""" + return _STATUS.get(order_id.strip().lower(), "No such order.") + + +if __name__ == "__main__": + mcp.run() # stdio transport by default diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/local-mcp-agent/pyproject.toml new file mode 100644 index 00000000..3d918428 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "codex-local-mcp-agent" +version = "0.0.1" +description = "UiPath Codex SDK local (stdio) MCP sample" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", + "mcp>=1.2.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/local-mcp-agent/uipath.json b/packages/uipath-codex-sdk/samples/local-mcp-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/local-mcp-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/.env.example b/packages/uipath-codex-sdk/samples/outlook-email-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/README.md b/packages/uipath-codex-sdk/samples/outlook-email-agent/README.md new file mode 100644 index 00000000..a22796bc --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/README.md @@ -0,0 +1,35 @@ +# Outlook email (Integration Service tool) + +A Codex agent that writes an email and sends it through the **Send Email** +activity of the *Microsoft Outlook 365* connector +(`uipath-microsoft-outlook365`, `POST /hubs/productivity/send-mail-v2`), +called as an Integration Service (IS) tool. + +| Model provides | Fixed (hidden from the model) | +| --- | --- | +| `message.toRecipients`, `message.subject`, `message.body.content` | `saveAsDraft=false` (query), `saveToSentItems=true`, `message.body.contentType="Text"` (body) | + +The activity takes `multipart/form-data` with the message as JSON in the +`body` part, which is what `body_structure={"contentType": "multipart", +"jsonBodySection": "body"}` declares. Without `saveAsDraft=false` the activity +only saves a draft. See [`integration-tool-agent`](../integration-tool-agent) +for how IS tools work and how to rebind the connection. + +**This sample really sends an email** from the connected mailbox. Change +`input.json` to email yourself. + +## Prerequisites + +* An **Outlook 365** connection. Put its id in `CONNECTION_ID` in `main.py` + and as `key` / `defaultValue` in `bindings.json`. +* Access to the connection's folder for whoever runs the agent (locally your + user; deployed, the robot account). + +```bash +cd samples/outlook-email-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json +``` + +The output is structured: `{to, subject}`. diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/bindings.json b/packages/uipath-codex-sdk/samples/outlook-email-agent/bindings.json new file mode 100644 index 00000000..301e736b --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/bindings.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "b172f2f8-e10f-4a88-b45e-2f7e62c550b2", + "value": { + "ConnectionId": { + "defaultValue": "b172f2f8-e10f-4a88-b45e-2f7e62c550b2", + "isExpression": false, + "displayName": "Outlook 365 connection" + } + }, + "metadata": { + "Connector": "uipath-microsoft-outlook365", + "UseConnectionService": "True", + "BindingsVersion": "2.2" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/codex.json b/packages/uipath-codex-sdk/samples/outlook-email-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/input.json b/packages/uipath-codex-sdk/samples/outlook-email-agent/input.json new file mode 100644 index 00000000..3d564289 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/input.json @@ -0,0 +1,4 @@ +{ + "recipient_email": "alexandru.groza@uipath.com", + "topic": "a short status update: the UiPath Codex SDK can now send emails through Integration Service" +} diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/main.py b/packages/uipath-codex-sdk/samples/outlook-email-agent/main.py new file mode 100644 index 00000000..03939799 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/main.py @@ -0,0 +1,81 @@ +"""Integration Service tool: the agent writes and sends an email with Outlook 365. + +``send_email`` runs the **Send Email** activity of the Microsoft Outlook 365 +connector (``uipath-microsoft-outlook365``, +``POST /hubs/productivity/send-mail-v2``) on your connection. The activity +takes ``multipart/form-data``: the message travels as JSON in the ``body`` +part. It is an *immediate* tool: the run never suspends on it. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool + +#: Id of your Outlook 365 connection (Integration Service → Connections → the +#: connection → its id in the URL). It is also the key of the connection +#: binding in bindings.json, so a deployment can rebind it. +CONNECTION_ID = "b172f2f8-e10f-4a88-b45e-2f7e62c550b2" + + +class Request(BaseModel): + recipient_email: str = Field(description="Who to send the email to") + topic: str = Field(description="What the email should be about") + + +class Output(BaseModel): + to: str = Field(description="Recipient(s) the email was sent to") + subject: str = Field(description="Subject of the sent email") + + +class EmailBody(BaseModel): + content: str = Field(description="Body of the email, as plain text.") + + +class Message(BaseModel): + toRecipients: str = Field( + description="Recipient email addresses, separated by commas." + ) + subject: str = Field(description="Subject of the email.") + body: EmailBody + + +class SendEmailIn(BaseModel): + message: Message + + +send_email = uipath_integration_tool( + "send_email", + connection_id=CONNECTION_ID, + connector_key="uipath-microsoft-outlook365", + object_name="send-mail-v2", + tool_path="/hubs/productivity/send-mail-v2", + method="POST", + input_schema=SendEmailIn, + # The message is sent as JSON in the "body" part of a multipart request. + body_structure={"contentType": "multipart", "jsonBodySection": "body"}, + parameters=[ + # Send it for real: by default the activity only saves a draft. + {"name": "saveAsDraft", "fieldLocation": "query", "fieldVariant": "static", "value": False}, + {"name": "saveToSentItems", "fieldLocation": "body", "fieldVariant": "static", "value": True}, + { + "name": "message.body.contentType", + "fieldLocation": "body", + "fieldVariant": "static", + "value": "Text", + }, + ], + description="Sends an email from the connected Outlook 365 mailbox.", +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Write a short, friendly email to {recipient_email} about {topic}, " + "then send it with send_email. Sign it 'Your UiPath Codex agent'. " + "Report who it was sent to and its subject." + ), + tools=[send_email], +) diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/outlook-email-agent/pyproject.toml new file mode 100644 index 00000000..f5ee6cc0 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-outlook-email-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that sends an email through the Outlook 365 Integration Service connector" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/outlook-email-agent/uipath.json b/packages/uipath-codex-sdk/samples/outlook-email-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/outlook-email-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/.env.example b/packages/uipath-codex-sdk/samples/process-resource-agent/.env.example new file mode 100644 index 00000000..f5a4a3ef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/.env.example @@ -0,0 +1,8 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= +# Optional, local runs only: the folder the agent "runs in". Orchestrator sets +# it for deployed jobs; durable tools without an explicit folder_path use it. +# UIPATH_FOLDER_PATH= diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/README.md b/packages/uipath-codex-sdk/samples/process-resource-agent/README.md new file mode 100644 index 00000000..4736d38f --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/README.md @@ -0,0 +1,97 @@ +# Process tool from an agent.json resource + +A Codex agent whose process tool is built from a low-code `agent.json` tool +resource, `argumentProperties` included. `resources/add_shipping.json` is the +resource as a low-code agent declares it; `process_tool_from_resource` turns +it into a durable process tool, as LangChain does for the same resource: + +```python +tool = process_tool_from_resource(RESOURCE, folder_path="Coded/a_plus_b") +``` + +The process is `a_plus_b`: input arguments `a` and `b` (integers), output `c` +(the sum). The resource pre-fills both inputs: + +| Input | `argumentProperties` | What the model sees | What the job gets | +| --- | --- | --- | --- | +| `a` | `argument`, `argumentPath: "$['order']['items']"` | optional, with a note that it is set from the agent input | `order.items` from the agent input (the model's value only when the input has none) | +| `b` | `static`, `value: 1000` | hidden | `1000` | + +With the input `{"order": {"items": 7}}` the model calls `Add_Shipping` with +`{}`, the job gets `{"a": 7, "b": 1000}` and returns `{"c": 1007}`. + +## How it works + +1. `process_tool_from_resource` validates the resource with the SDK's + `AgentProcessToolResourceConfig`: `type` Process, Agent, Api, + ProcessOrchestration, Flow or Function (any case). The tool name is the + resource `name` sanitized as in LangChain (`Add Shipping` → + `Add_Shipping`), the process is `properties.processName`. +2. The model-facing input schema hides the static field and marks the + argument field optional. Every `argumentProperties` entry needs + `isSensitive` (the SDK model requires it; `agent.json` always has it). +3. On a call, the pre-filled values are merged into the model's arguments and + the job starts with the merged input. The run suspends until the job + finishes, then resumes with its output, as for any durable process tool + (see `samples/durable-process-tool`). +4. `properties.folderPath` is ignored, as in low-code: the job starts in the + folder the agent runs in (`UIPATH_FOLDER_PATH`). This sample passes + `folder_path="Coded/a_plus_b"` so it also works from a Personal Workspace; + omit it when the agent is deployed next to the process. + +## Prerequisites + +* An `a_plus_b` process deployed in an Orchestrator folder: input arguments + `a`, `b` (Int32) and an output argument `c` (Int32) with the sum. +* `uipath auth` against the tenant. Set `PROCESS_FOLDER` in `main.py` (and + `folderPath` in `bindings.json`) to the folder of your `a_plus_b` release. + +## Run locally + +```bash +cd samples/process-resource-agent +uv sync +uv run uipath auth +uv run uipath run agent --file input.json # suspends on the a_plus_b job +# ... the job finishes ... +uv run uipath run agent --resume # → "c is 1007" +``` + +If the job is still running, `--resume` suspends again on it; resume once +more later. + +## Run on Orchestrator + +Publish the agent (`uipath pack` and `uipath publish`) and start a job with +`input.json`: the job suspends on `a_plus_b`, Orchestrator resumes it when the +child job finishes, and the job ends Successful with `c is 1007`. + +## Evaluate with mocked tools + +`evaluations/eval-sets/evaluation-set-mocked.json` runs the agent with +`a_plus_b` mocked, so no Orchestrator job is started and the run never +suspends. + +The mockito behavior is named after the resource (`Add Shipping`, not the +model-facing `Add_Shipping`) and matches on the merged arguments +`{"a": 7, "b": 1000}`: the mocker sees the pre-filled values, as in +LangChain. + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-mocked.json --no-report +``` + +See the main README's "Mocking tools in evals" section. + +## Trace + +``` +Agent run - agent +├─ LLM call +├─ Tool call - Add_Shipping toolType Process, input {"a": 7, "b": 1000} (the merged arguments) +├─ LLM call +└─ Agent output +``` + +When mocked, `Tool call - Add_Shipping` contains a `processTool` span named +`Simulated result: a_plus_b`. diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/codex.json b/packages/uipath-codex-sdk/samples/process-resource-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/eval-sets/evaluation-set-mocked.json b/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/eval-sets/evaluation-set-mocked.json new file mode 100644 index 00000000..53127b1e --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/eval-sets/evaluation-set-mocked.json @@ -0,0 +1,52 @@ +{ + "version": "1.0", + "id": "process-resource-agent-mocked", + "name": "Order total (mocked a_plus_b)", + "evaluatorRefs": [ + "evaluator-contains", + "evaluator-tool-call-order" + ], + "evaluations": [ + { + "id": "prefilled-mockito", + "name": "Mockito: matched on the pre-filled arguments", + "inputs": { + "order": { + "items": 7 + } + }, + "evaluationCriterias": { + "evaluator-contains": { + "searchText": "1007" + }, + "evaluator-tool-call-order": { + "toolCallsOrder": [ + "Add_Shipping" + ] + } + }, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "Add Shipping", + "arguments": { + "kwargs": { + "a": 7, + "b": 1000 + } + }, + "then": [ + { + "type": "return", + "value": { + "c": 1007 + } + } + ] + } + ] + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/contains.json b/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/contains.json new file mode 100644 index 00000000..72d2d1cb --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/contains.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains", + "description": "Check that the output contains the expected text (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/tool-call-order.json b/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/tool-call-order.json new file mode 100644 index 00000000..89afed8d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/evaluations/evaluators/tool-call-order.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-tool-call-order", + "description": "Check which tools the agent called, in order.", + "evaluatorTypeId": "uipath-tool-call-order", + "evaluatorConfig": { + "name": "Tool Call Order", + "strict": false + } +} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/input.json b/packages/uipath-codex-sdk/samples/process-resource-agent/input.json new file mode 100644 index 00000000..2caf510d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/input.json @@ -0,0 +1 @@ +{"order": {"items": 7}} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/main.py b/packages/uipath-codex-sdk/samples/process-resource-agent/main.py new file mode 100644 index 00000000..db7cbbd0 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/main.py @@ -0,0 +1,46 @@ +"""Process tool from an agent.json resource, with pre-filled arguments. + +``resources/add_shipping.json`` is a process tool resource as a low-code +agent's ``agent.json`` declares it. ``process_tool_from_resource`` turns it +into a durable process tool, as LangChain does for the same resource: + +* ``a`` comes from the agent input (``argumentProperties`` variant + ``argument``, path ``$['order']['items']``). The model sees it as optional + and fills it only when the input has no value. +* ``b`` is fixed to 1000 (variant ``static``) and hidden from the model. + +The job gets ``{"a": , "b": 1000}`` and the run suspends until it +finishes, as with any durable process tool. +""" + +import json +from pathlib import Path + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, process_tool_from_resource + +#: Orchestrator folder of the a_plus_b release. ``properties.folderPath`` in +#: the resource is ignored, as in low-code (the job runs in the folder the +#: agent runs in); pass it here to point the tool at another folder. +PROCESS_FOLDER = "Coded/a_plus_b" + +RESOURCE = json.loads( + (Path(__file__).parent / "resources" / "add_shipping.json").read_text() +) + + +class Order(BaseModel): + items: int = Field(description="Number of items in the order") + + +class Request(BaseModel): + order: Order + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + prompt="Call Add_Shipping once and report the result c.", + tools=[process_tool_from_resource(RESOURCE, folder_path=PROCESS_FOLDER)], +) diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/process-resource-agent/pyproject.toml new file mode 100644 index 00000000..b2e457da --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-process-resource-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent with a process tool built from an agent.json resource with argumentProperties" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/resources/add_shipping.json b/packages/uipath-codex-sdk/samples/process-resource-agent/resources/add_shipping.json new file mode 100644 index 00000000..e0defa71 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/resources/add_shipping.json @@ -0,0 +1,24 @@ +{ + "$resourceType": "tool", + "type": "Process", + "name": "Add Shipping", + "description": "Adds the shipping fee to the number of items of an order.", + "isEnabled": true, + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "integer", "description": "Number of items"}, + "b": {"type": "integer", "description": "Shipping fee"} + }, + "required": ["a", "b"] + }, + "outputSchema": { + "type": "object", + "properties": {"c": {"type": "integer", "description": "The sum a + b"}} + }, + "properties": {"processName": "a_plus_b", "folderPath": "Coded/a_plus_b"}, + "argumentProperties": { + "$['a']": {"variant": "argument", "argumentPath": "$['order']['items']", "isSensitive": false}, + "$['b']": {"variant": "static", "value": 1000, "isSensitive": false} + } +} diff --git a/packages/uipath-codex-sdk/samples/process-resource-agent/uipath.json b/packages/uipath-codex-sdk/samples/process-resource-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/process-resource-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/.env.example b/packages/uipath-codex-sdk/samples/quickstart-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/README.md b/packages/uipath-codex-sdk/samples/quickstart-agent/README.md new file mode 100644 index 00000000..00782109 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/README.md @@ -0,0 +1,40 @@ +# Quickstart + +The smallest Codex agent: one prompt template, no tools. The model call goes +through the UiPath LLM Gateway with your tenant's models; no OpenAI API key is +needed. + +```python +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", +) +``` + +## Run locally + +```bash +cd samples/quickstart-agent +uv sync +uv run uipath auth +uv run uipath run agent --file input.json # {"topic": "robots"} +``` + +The output is `{"output": ""}`. + +## Run on Orchestrator + +```bash +uv run uipath pack +uv run uipath publish --my-workspace +``` + +Start a job with `{"topic": "robots"}`. + +## Trace + +``` +Agent run - agent +├─ LLM call → Model run +└─ Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/codex.json b/packages/uipath-codex-sdk/samples/quickstart-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/input.json b/packages/uipath-codex-sdk/samples/quickstart-agent/input.json new file mode 100644 index 00000000..999f55dd --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/input.json @@ -0,0 +1,3 @@ +{ + "topic": "robots" +} diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/main.py b/packages/uipath-codex-sdk/samples/quickstart-agent/main.py new file mode 100644 index 00000000..def0c226 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/main.py @@ -0,0 +1,8 @@ +"""Quickstart Codex agent: tell me a joke, via the UiPath LLM Gateway.""" + +from uipath_codex_sdk import UiPathCodexAgent + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", +) diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/quickstart-agent/pyproject.toml new file mode 100644 index 00000000..10e3bebe --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-quickstart-agent" +version = "0.0.1" +description = "UiPath Codex SDK Quickstart Agent" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/quickstart-agent/uipath.json b/packages/uipath-codex-sdk/samples/quickstart-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/quickstart-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/README.md b/packages/uipath-codex-sdk/samples/remote-mcp-agent/README.md new file mode 100644 index 00000000..5b4c8d35 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/README.md @@ -0,0 +1,36 @@ +# Remote MCP server (streamable HTTP) + +A Codex agent that uses a tool from an MCP server reached over HTTP. +`orders_http_server.py` serves one tool, `get_order_status`, on +`http://127.0.0.1:8931/mcp`. + +```python +mcp_servers={ + "orders": RemoteMcpServer( + url="http://127.0.0.1:8931/mcp", + bearer_token_env_var="ORDERS_TOKEN", + bearer_token="demo-secret-token", + ) +} +``` + +Codex sends the token as `Authorization: Bearer`. It silently skips a remote +server whose `bearer_token_env_var` is not set; giving a literal +`bearer_token` makes the runtime set the variable for the Codex process. For +a UiPath-hosted MCP server, use `bearer_token_env_var="UIPATH_ACCESS_TOKEN"` +instead (see `samples/uipath-mcp-agent`). + +## Run locally + +```bash +cd samples/remote-mcp-agent +uv sync +uv run uipath auth +uv run python orders_http_server.py & # in its own shell +uv run uipath run agent --file input.json # {"order_id": "A-1003"} +``` + +The output reports "Delivered on 2026-09-05, signed for by J. Popescu." + +The server runs on your machine, so this sample is for local runs; on +Orchestrator, point `url` at a server the robot can reach. diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/codex.json b/packages/uipath-codex-sdk/samples/remote-mcp-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/input.json b/packages/uipath-codex-sdk/samples/remote-mcp-agent/input.json new file mode 100644 index 00000000..5f10720d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/input.json @@ -0,0 +1,3 @@ +{ + "order_id": "A-1003" +} diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/main.py b/packages/uipath-codex-sdk/samples/remote-mcp-agent/main.py new file mode 100644 index 00000000..e323fa71 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/main.py @@ -0,0 +1,39 @@ +"""Codex agent that calls a remote (streamable-HTTP) MCP server tool. + +Start orders_http_server.py first (see its docstring). The bearer token Codex +sends is read from the ORDERS_TOKEN env var; for a UiPath AgentHub-hosted MCP +server you would instead point bearer_token_env_var at "UIPATH_ACCESS_TOKEN". +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import RemoteMcpServer, UiPathCodexAgent + + +class OrderQuery(BaseModel): + order_id: str = Field(description="The order id to look up, e.g. 'A-1001'.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Look up the status of order '{order_id}' using the orders tool and report it verbatim.", + input_schema=OrderQuery, + sandbox="full-access", + approval_mode="deny_all", + base_instructions=( + "You have an 'orders' MCP tool. Always call it to look up order " + "status; never guess. Report exactly what the tool returns." + ), + mcp_servers={ + "orders": RemoteMcpServer( + url="http://127.0.0.1:8931/mcp", + # Codex sends this as `Authorization: Bearer`. Providing a literal + # token makes the sample self-contained: the runtime injects it into + # the codex env, so the server is reached regardless of ambient env. + # (A real UiPath AgentHub MCP server would instead use + # bearer_token_env_var="UIPATH_ACCESS_TOKEN".) + bearer_token_env_var="ORDERS_TOKEN", + bearer_token="demo-secret-token", + ) + }, +) diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/orders_http_server.py b/packages/uipath-codex-sdk/samples/remote-mcp-agent/orders_http_server.py new file mode 100644 index 00000000..49a56e56 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/orders_http_server.py @@ -0,0 +1,26 @@ +"""A streamable-HTTP MCP server (the 'remote' transport) exposing an order tool. + +Run it in its own shell before the agent: + uv run python orders_http_server.py +It listens on http://127.0.0.1:8931/mcp +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("orders") + +_STATUS = { + "a-1001": "Shipped, arriving 2026-09-10 via the Bucharest hub.", + "a-1002": "Processing, awaiting stock (backordered 3 days).", + "a-1003": "Delivered on 2026-09-05, signed for by J. Popescu.", +} + + +@mcp.tool() +def get_order_status(order_id: str) -> str: + """Return the current fulfillment status for an order id.""" + return _STATUS.get(order_id.strip().lower(), "No such order.") + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", host="127.0.0.1", port=8931, json_response=True) diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/remote-mcp-agent/pyproject.toml new file mode 100644 index 00000000..e8a50f7a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "codex-remote-mcp-agent" +version = "0.0.1" +description = "UiPath Codex SDK remote (streamable-HTTP) MCP sample" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", + "mcp>=2.0.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/remote-mcp-agent/uipath.json b/packages/uipath-codex-sdk/samples/remote-mcp-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/remote-mcp-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/.env.example b/packages/uipath-codex-sdk/samples/slack-message-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/README.md b/packages/uipath-codex-sdk/samples/slack-message-agent/README.md new file mode 100644 index 00000000..53e79777 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/README.md @@ -0,0 +1,49 @@ +# Slack message (Integration Service tools) + +A Codex agent that sends a direct message on Slack **as the bot**, through two +activities of the *Slack* connector (`uipath-salesforce-slack`), called as +Integration Service (IS) tools: + +| Tool | Activity | Model provides | Fixed (hidden from the model) | +| --- | --- | --- | --- | +| `get_slack_user_by_email` | Get User by Email or User Id — `GET /UsersByEmail/{usersByEmailId}` | the email | `By=Email` (query) | +| `send_slack_message_to_user` | Send Message to User — `POST /send_message_to_user_v2` | `channel` (user id), `messageToSend` | `send_as=bot` (query) | + +The send activity needs a Slack **user id**, not an email, so the model looks +the user up first and passes its id. Both tools answer immediately; the run +never suspends. See [`integration-tool-agent`](../integration-tool-agent) for +how IS tools work, copying tools from a low-code `agent.json`, and rebinding. + +**This sample really sends a Slack message.** Change `input.json` to message +yourself. + +## Prerequisites + +* A **Slack** connection whose app bot can message the recipient (the bot + must be installed in the workspace). Put its id in `CONNECTION_ID` in + `main.py` and as `key` / `defaultValue` in `bindings.json`. +* Access to the connection's folder for whoever runs the agent (locally your + user; deployed, the robot account). + +```bash +cd samples/slack-message-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath run agent --file input.json +``` + +The output is structured: `{recipient_user_id, message_ts, text}`. + +## The trace + +``` +Agent run - agent +├── LLM call +├── Tool call - get_slack_user_by_email (toolType Integration) +│ └── get_slack_user_by_email (integrationTool) → activity_invoke … +├── LLM call +├── Tool call - send_slack_message_to_user (toolType Integration) +│ └── send_slack_message_to_user (integrationTool) → activity_invoke … +├── LLM call +└── Agent output +``` diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/bindings.json b/packages/uipath-codex-sdk/samples/slack-message-agent/bindings.json new file mode 100644 index 00000000..88cc5b6e --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/bindings.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "8e1d0859-a9c0-4d11-b3e0-849a8abeb7b8", + "value": { + "ConnectionId": { + "defaultValue": "8e1d0859-a9c0-4d11-b3e0-849a8abeb7b8", + "isExpression": false, + "displayName": "Slack connection" + } + }, + "metadata": { + "Connector": "uipath-salesforce-slack", + "UseConnectionService": "True", + "BindingsVersion": "2.2" + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/codex.json b/packages/uipath-codex-sdk/samples/slack-message-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/input.json b/packages/uipath-codex-sdk/samples/slack-message-agent/input.json new file mode 100644 index 00000000..bbe1b73e --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/input.json @@ -0,0 +1,4 @@ +{ + "recipient_email": "alexandru.groza@uipath.com", + "message": "Hello from a UiPath Codex agent! This message was sent by the Slack bot through an Integration Service tool." +} diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/main.py b/packages/uipath-codex-sdk/samples/slack-message-agent/main.py new file mode 100644 index 00000000..c2324794 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/main.py @@ -0,0 +1,91 @@ +"""Integration Service tools: the agent sends a Slack message as the bot. + +Two activities of the Slack connector (``uipath-salesforce-slack``) on one +connection, both *immediate* tools (answered as soon as Integration Service +returns; the run never suspends on them): + +* ``get_slack_user_by_email`` — **Get User by Email or User Id** + (``GET /UsersByEmail/{usersByEmailId}``), to turn an email into the Slack + user id the send activity needs; +* ``send_slack_message_to_user`` — **Send Message to User** + (``POST /send_message_to_user_v2``), sent as the app bot. +""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool + +#: Id of your Slack connection (Integration Service → Connections → the +#: connection → its id in the URL). It is also the key of the connection +#: binding in bindings.json, so a deployment can rebind it. +CONNECTION_ID = "8e1d0859-a9c0-4d11-b3e0-849a8abeb7b8" +SLACK = "uipath-salesforce-slack" + + +class Request(BaseModel): + recipient_email: str = Field(description="Email of the Slack user to message") + message: str = Field(description="What the message should say") + + +class Output(BaseModel): + recipient_user_id: str = Field(description="Slack user id the message went to") + message_ts: str = Field(description="Slack timestamp (id) of the sent message") + text: str = Field(description="The text that was sent") + + +class UserByEmailIn(BaseModel): + usersByEmailId: str = Field(description="Email address of the Slack user.") + + +class SendMessageIn(BaseModel): + channel: str = Field( + description="Slack user id of the recipient (e.g. 'U02AB3CD4EF'), " + "as returned by get_slack_user_by_email." + ) + messageToSend: str = Field(description="Text of the message (Slack mrkdwn).") + + +get_user = uipath_integration_tool( + "get_slack_user_by_email", + connection_id=CONNECTION_ID, + connector_key=SLACK, + object_name="UsersByEmail_GET", + tool_path="/UsersByEmail/{usersByEmailId}", # {usersByEmailId} → path + method="GETBYID", + input_schema=UserByEmailIn, + # Look the user up by email (the activity also accepts a user id). + parameters=[ + {"name": "By", "fieldLocation": "query", "fieldVariant": "static", "value": "Email"} + ], + description="Finds a Slack user by email. Returns the user, including its id.", +) + +send_message = uipath_integration_tool( + "send_slack_message_to_user", + connection_id=CONNECTION_ID, + connector_key=SLACK, + object_name="send_message_to_user_v2", + tool_path="/send_message_to_user_v2", + method="POST", + input_schema=SendMessageIn, # channel, messageToSend → JSON body + # Send as the app bot (the alternative is "user", the connection's user). + parameters=[ + {"name": "send_as", "fieldLocation": "query", "fieldVariant": "static", "value": "bot"} + ], + description="Sends a direct message to one Slack user, as the bot.", +) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + input_schema=Request, + output_schema=Output, + prompt=( + "Send this Slack message to {recipient_email}: {message}\n" + "First look the user up by email with get_slack_user_by_email, then " + "send the message exactly as given with send_slack_message_to_user, " + "using the user's id. Report the recipient id, the message timestamp " + "and the text that was sent." + ), + tools=[get_user, send_message], +) diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/slack-message-agent/pyproject.toml new file mode 100644 index 00000000..2940b97c --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-slack-message-agent" +version = "0.0.1" +description = "UiPath Codex SDK agent that sends a Slack message as the bot through Integration Service" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/slack-message-agent/uipath.json b/packages/uipath-codex-sdk/samples/slack-message-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/slack-message-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/.env.example b/packages/uipath-codex-sdk/samples/structured-output-agent/.env.example new file mode 100644 index 00000000..0236576d --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/.env.example @@ -0,0 +1,4 @@ +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/README.md b/packages/uipath-codex-sdk/samples/structured-output-agent/README.md new file mode 100644 index 00000000..8c55f8cc --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/README.md @@ -0,0 +1,44 @@ +# Structured output + +A Codex agent that returns a typed result. `output_schema` makes Codex ask +the model for native structured output in that shape, and the runtime +validates the result against it. + +```python +class Joke(BaseModel): + setup: str = Field(description="The setup line of the joke.") + punchline: str = Field(description="The punchline.") + rating: int = Field(description="How funny, 1-10.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", + input_schema=JokeInput, + output_schema=Joke, +) +``` + +Both schemas become the entry point's input and output schemas +(`uipath init` writes them to `entry-points.json`). + +## Run locally + +```bash +cd samples/structured-output-agent +uv sync +uv run uipath auth +uv run uipath run agent --file input.json # {"topic": "robots"} +``` + +The output is `{"setup": "...", "punchline": "...", "rating": 7}`. + +## Evaluate + +`evaluations/eval-sets/evaluation-set-default.json` scores three topics with +a JSON similarity evaluator (against an expected joke) and a contains +evaluator (the joke mentions its topic): + +```bash +uv run uipath eval agent evaluations/eval-sets/evaluation-set-default.json --no-report +``` diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/codex.json b/packages/uipath-codex-sdk/samples/structured-output-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/eval-sets/evaluation-set-default.json b/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/eval-sets/evaluation-set-default.json new file mode 100644 index 00000000..bfcc1e0b --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/eval-sets/evaluation-set-default.json @@ -0,0 +1,68 @@ +{ + "version": "1.0", + "id": "structured-output-agent-default", + "name": "Structured output agent", + "evaluatorRefs": [ + "evaluator-json-similarity", + "evaluator-contains-topic" + ], + "evaluations": [ + { + "id": "joke-robots", + "name": "Joke about robots", + "inputs": { + "topic": "robots" + }, + "evaluationCriterias": { + "evaluator-json-similarity": { + "expectedOutput": { + "setup": "Why did the robot go on vacation?", + "punchline": "To recharge its batteries.", + "rating": 7 + } + }, + "evaluator-contains-topic": { + "searchText": "robot" + } + } + }, + { + "id": "joke-cats", + "name": "Joke about cats", + "inputs": { + "topic": "cats" + }, + "evaluationCriterias": { + "evaluator-json-similarity": { + "expectedOutput": { + "setup": "Why did the cat sit on the computer?", + "punchline": "To keep an eye on the mouse.", + "rating": 7 + } + }, + "evaluator-contains-topic": { + "searchText": "cat" + } + } + }, + { + "id": "joke-coffee", + "name": "Joke about coffee", + "inputs": { + "topic": "coffee" + }, + "evaluationCriterias": { + "evaluator-json-similarity": { + "expectedOutput": { + "setup": "Why did the coffee file a police report?", + "punchline": "It got mugged.", + "rating": 7 + } + }, + "evaluator-contains-topic": { + "searchText": "coffee" + } + } + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/contains-topic.json b/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/contains-topic.json new file mode 100644 index 00000000..552219e1 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/contains-topic.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "id": "evaluator-contains-topic", + "description": "Check that the joke mentions its topic somewhere in the output (case-insensitive).", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Mentions Topic", + "case_sensitive": false, + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/json-similarity.json b/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/json-similarity.json new file mode 100644 index 00000000..3b14c5d3 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/evaluations/evaluators/json-similarity.json @@ -0,0 +1,10 @@ +{ + "version": "1.0", + "id": "evaluator-json-similarity", + "description": "Compare the whole Joke output (setup, punchline, rating) with a reference joke. Text fields score partially, so this measures shape and closeness rather than an exact answer.", + "evaluatorTypeId": "uipath-json-similarity", + "evaluatorConfig": { + "name": "Joke JSON Similarity", + "targetOutputKey": "*" + } +} diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/input.json b/packages/uipath-codex-sdk/samples/structured-output-agent/input.json new file mode 100644 index 00000000..999f55dd --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/input.json @@ -0,0 +1,3 @@ +{ + "topic": "robots" +} diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/main.py b/packages/uipath-codex-sdk/samples/structured-output-agent/main.py new file mode 100644 index 00000000..8875b4f0 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/main.py @@ -0,0 +1,23 @@ +"""Codex agent returning structured output (validated against a Pydantic model).""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent + + +class JokeInput(BaseModel): + topic: str = Field(description="What the joke should be about.") + + +class Joke(BaseModel): + setup: str = Field(description="The setup line of the joke.") + punchline: str = Field(description="The punchline.") + rating: int = Field(description="How funny, 1-10.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", + input_schema=JokeInput, + output_schema=Joke, +) diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/project.uiproj b/packages/uipath-codex-sdk/samples/structured-output-agent/project.uiproj new file mode 100644 index 00000000..621de65a --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/project.uiproj @@ -0,0 +1,6 @@ +{ + "ProjectType": "Agent", + "Name": "codex-structured-output-agent", + "Description": "UiPath Codex SDK structured-output sample", + "MainFile": null +} \ No newline at end of file diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/structured-output-agent/pyproject.toml new file mode 100644 index 00000000..436a8dfa --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-structured-output-agent" +version = "0.0.1" +description = "UiPath Codex SDK structured-output sample" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/structured-output-agent/uipath.json b/packages/uipath-codex-sdk/samples/structured-output-agent/uipath.json new file mode 100644 index 00000000..0b875e60 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/structured-output-agent/uipath.json @@ -0,0 +1,3 @@ +{ + "id": "5972b5e4-5ddb-46b7-a70f-107467a2f6ad" +} \ No newline at end of file diff --git a/packages/uipath-codex-sdk/samples/uipath-mcp-agent/README.md b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/README.md new file mode 100644 index 00000000..e0510731 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/README.md @@ -0,0 +1,44 @@ +# UiPath-hosted MCP server + +A Codex agent that uses the tools of an MCP server hosted in your tenant +(Orchestrator → folder → **MCP Servers**). The sample points at an +`arithmetic-operations-mcp` server with tools such as `add`. + +```python +mcp_servers={ + "uipath_mcp": RemoteMcpServer( + url=MCP_URL, # the server's "Copy URL" + bearer_token_env_var="UIPATH_ACCESS_TOKEN", + startup_timeout_sec=60, + tool_timeout_sec=900, + default_tools_approval_mode="approve", + ) +} +``` + +* The server authenticates with the run's own UiPath token + (`UIPATH_ACCESS_TOKEN`), which `uipath auth` writes locally and the robot + sets for a job. +* UiPath MCP tools carry no read-only annotation, so Codex would ask for + approval on every call, and `approval_mode="deny_all"` would reject it. + `default_tools_approval_mode="approve"` pre-approves this trusted server's + tools. +* `sandbox="read-only"` keeps the model from working around a missing tool + with shell commands. + +## Prerequisites + +An MCP server in a tenant folder. Set `MCP_URL` in `main.py` (or the +`UIPATH_MCP_URL` environment variable) to its URL: +`https://///agenthub_/mcp//`. + +## Run locally + +```bash +cd samples/uipath-mcp-agent +uv sync +uv run uipath auth +uv run uipath run agent --file input.json # {"task": "What is 17 plus 23? Use the MCP tool."} +``` + +`CODEX_MODEL` overrides the model. diff --git a/packages/uipath-codex-sdk/samples/uipath-mcp-agent/codex.json b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/uipath-mcp-agent/input.json b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/input.json new file mode 100644 index 00000000..9d698bc5 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/input.json @@ -0,0 +1,3 @@ +{ + "task": "What is 17 plus 23? Use the MCP tool." +} diff --git a/packages/uipath-codex-sdk/samples/uipath-mcp-agent/main.py b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/main.py new file mode 100644 index 00000000..7ba0c115 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/main.py @@ -0,0 +1,49 @@ +"""Codex agent that uses a UiPath-hosted (AgentHub) MCP server. + +The MCP URL comes from UIPATH_MCP_URL (Orchestrator -> folder -> MCP Servers -> +Copy URL). Codex authenticates with the same token `uipath auth` wrote to .env. +""" + +import os + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import RemoteMcpServer, UiPathCodexAgent + +MCP_URL = os.environ.get( + "UIPATH_MCP_URL", + "https://alpha.uipath.com/AdminStudioTest/al3x/agenthub_/mcp/" + "cbdabce1-ae2b-4dd6-99f9-632795c88ad9/arithmetic-operations-mcp", +) + + +class Task(BaseModel): + task: str = Field(description="What to ask the agent, e.g. 'What is 17 * 23?'") + + +agent = UiPathCodexAgent( + model=os.environ.get("CODEX_MODEL", "gpt-5.6-terra"), + prompt="{task}", + input_schema=Task, + # read-only: if the MCP tool isn't available the agent must say so, + # instead of improvising with shell commands. + sandbox="read-only", + approval_mode="deny_all", + base_instructions=( + "You have a 'uipath_mcp' MCP server with arithmetic tools. Always use its " + "tools to compute results; never compute them yourself. Report which " + "tool you called and its exact result." + ), + mcp_servers={ + "uipath_mcp": RemoteMcpServer( + url=MCP_URL, + bearer_token_env_var="UIPATH_ACCESS_TOKEN", + startup_timeout_sec=60, + tool_timeout_sec=900, + # UiPath MCP tools carry no readOnlyHint annotation, so Codex's + # default ("auto") would ask for approval, which deny_all rejects. + # This is a trusted tenant server: pre-approve its tools. + default_tools_approval_mode="approve", + ) + }, +) diff --git a/packages/uipath-codex-sdk/samples/uipath-mcp-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/pyproject.toml new file mode 100644 index 00000000..9c6083c0 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-uipath-mcp-agent" +version = "0.0.1" +description = "UiPath Codex SDK sample: UiPath-hosted (AgentHub) MCP server" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/uipath-mcp-agent/uipath.json b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/uipath.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/uipath-mcp-agent/uipath.json @@ -0,0 +1 @@ +{} diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/__init__.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/__init__.py new file mode 100644 index 00000000..00aa6527 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/__init__.py @@ -0,0 +1,115 @@ +"""UiPath Codex SDK Integration.""" + +from typing import Any + +from .agent import CodexAgent, UiPathCodexAgent +from .attachment_tools import ( + UiPathAnalyzeFilesTool, + analyze_files_tool_from_resource, + uipath_analyze_files_tool, +) +from .attachments import AttachmentStore, UiPathAttachment +from .batch_transform_tools import ( + UiPathBatchTransformAttachmentsTool, + batch_transform_tool_from_resource, + uipath_batch_transform_attachments_tool, +) +from .context_tools import ( + UiPathContextTool, + UiPathDeepRagAttachmentsTool, + UiPathDeepRagTool, + context_tool_from_resource, + deep_rag_tool_from_resource, + uipath_context_tool, + uipath_deep_rag_attachments_tool, + uipath_deep_rag_tool, +) +from .escalation_tools import ( + UiPathEscalationTool, + escalation_tool_from_resource, + uipath_escalation_tool, +) +from .integration_tools import ( + UiPathIntegrationTool, + integration_tool_from_resource, + uipath_integration_tool, +) +from .ixp_tools import ( + UiPathDocumentValidationTool, + UiPathIxpExtractionTool, + ixp_extraction_tool_from_resource, + uipath_document_validation_tool, + uipath_ixp_extraction_tool, + vs_escalation_tool_from_resource, +) +from .mcp import RemoteMcpServer, StdioMcpServer +from .process_tools import process_tool_from_resource +from .tools import ( + AnyUiPathTool, + DurableToolContext, + ToolContext, + UiPathDurableTool, + UiPathDurableToolError, + UiPathDurableToolPending, + UiPathProcessTool, + UiPathTool, + UiPathToolError, + uipath_agent_tool, + uipath_process_tool, +) + +__all__ = [ + "AnyUiPathTool", + "AttachmentStore", + "CodexAgent", + "DurableToolContext", + "RemoteMcpServer", + "StdioMcpServer", + "ToolContext", + "UiPathAnalyzeFilesTool", + "UiPathAttachment", + "UiPathBatchTransformAttachmentsTool", + "UiPathCodexAgent", + "UiPathContextTool", + "UiPathDeepRagAttachmentsTool", + "UiPathDocumentValidationTool", + "UiPathDeepRagTool", + "UiPathDurableTool", + "UiPathDurableToolError", + "UiPathDurableToolPending", + "UiPathEscalationTool", + "UiPathIntegrationTool", + "UiPathIxpExtractionTool", + "UiPathProcessTool", + "UiPathTool", + "UiPathToolError", + "analyze_files_tool_from_resource", + "batch_transform_tool_from_resource", + "context_tool_from_resource", + "deep_rag_tool_from_resource", + "escalation_tool_from_resource", + "integration_tool_from_resource", + "ixp_extraction_tool_from_resource", + "process_tool_from_resource", + "register_middleware", + "uipath_agent_tool", + "uipath_analyze_files_tool", + "uipath_batch_transform_attachments_tool", + "uipath_context_tool", + "uipath_deep_rag_attachments_tool", + "uipath_deep_rag_tool", + "uipath_document_validation_tool", + "uipath_escalation_tool", + "uipath_integration_tool", + "uipath_ixp_extraction_tool", + "uipath_process_tool", + "vs_escalation_tool_from_resource", +] + + +def __getattr__(name: str) -> Any: + if name == "register_middleware": + from .middlewares import register_middleware + + return register_middleware + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/__init__.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/codex.json.template b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/codex.json.template new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/codex.json.template @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/main.py.template b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/main.py.template new file mode 100644 index 00000000..e79afe3b --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/_templates/main.py.template @@ -0,0 +1,16 @@ +"""A minimal Codex SDK agent that answers a prompt via the UiPath LLM Gateway.""" + +from pydantic import BaseModel, Field + +from uipath_codex_sdk import UiPathCodexAgent + + +class Input(BaseModel): + topic: str = Field(description="What the joke should be about.") + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Tell me a short, clean joke about {topic}.", + input_schema=Input, +) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/cli_new.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/cli_new.py new file mode 100644 index 00000000..b1216bc3 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_cli/cli_new.py @@ -0,0 +1,59 @@ +import os +import shutil + +import click +from uipath._cli._utils._console import ConsoleLogger +from uipath._cli.middlewares import MiddlewareResult + +console = ConsoleLogger() + + +def generate_script(target_directory): + for template, target in ( + ("main.py.template", "main.py"), + ("codex.json.template", "codex.json"), + ): + src = os.path.join(os.path.dirname(__file__), "_templates", template) + shutil.copyfile(src, os.path.join(target_directory, target)) + + +def generate_pyproject(target_directory, project_name): + toml_content = f"""[project] +name = "{project_name}" +version = "0.0.1" +description = "{project_name}" +authors = [{{ name = "John Doe", email = "john.doe@myemail.com" }}] +dependencies = [ + "uipath-codex-sdk>=0.1.0, <0.2.0", +] +requires-python = ">=3.11" + +[dependency-groups] +dev = [ + "uipath-dev>=0.0.74", +] +""" + with open(os.path.join(target_directory, "pyproject.toml"), "w") as f: + f.write(toml_content) + + +def codex_new_middleware(name: str) -> MiddlewareResult: + """Middleware to create a demo Codex SDK agent.""" + directory = os.getcwd() + try: + with console.spinner(f"Creating new agent {name} in current directory ..."): + generate_script(directory) + console.success("Created 'main.py' file.") + console.success("Created 'codex.json' file.") + generate_pyproject(directory, name) + console.success("Created 'pyproject.toml' file.") + init_command = "uipath init" + run_command = """uipath run agent '{"topic": "robots"}'""" + console.hint( + f""" Initialize project: {click.style(init_command, fg="cyan")}""" + ) + console.hint(f""" Run agent: {click.style(run_command, fg="cyan")}""") + return MiddlewareResult(should_continue=False) + except Exception as e: + console.error(f"Error creating demo agent {str(e)}") + return MiddlewareResult(should_continue=False, should_include_stacktrace=True) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/_fixed_args.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_fixed_args.py new file mode 100644 index 00000000..9a177587 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/_fixed_args.py @@ -0,0 +1,675 @@ +"""Pre-filled tool arguments: paths, schema hiding and the call-time merge. + +Shared by every tool with fields the model does not fill: static values, +values read from the agent input, text templates and arrays built from those +(agent.json ``argumentProperties``, or ``static_args`` / ``args_from_input`` +in code). A leaf module: it imports only the stdlib and ``uipath.agent``, so +``tools``, ``escalation_tools`` and ``integration_tools`` can all use it. + +Names are paths into the tool input: ``a.b`` creates objects on the way, +``x[*].y`` sets ``y`` on every element of ``x``, ``x[*]`` sets every element +(an empty or absent ``x`` becomes ``[value]``) and ``x[0].b`` addresses one +element. LangChain's JSONPath keys (``$['a']['b']``, ``$.a.b``) are +converted with :func:`input_name`. +""" + +from __future__ import annotations + +import copy +import json +import logging +import re +from dataclasses import dataclass +from typing import Any, Iterable, Mapping, Sequence + +logger = logging.getLogger(__name__) + +_SEGMENT_RE = re.compile(r"\[(\*|\d+)\]") +#: One JSONPath bracket: ``['name']`` / ``["name"]``, ``[*]`` or ``[N]``. +_BRACKET_RE = re.compile( + r"""\[\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"|(\*|\d+))\s*\]""" +) +_NAME_RE = re.compile(r"[^.\[\]]+") + + +# -------------------------------------------------------------------------- +# path names +# -------------------------------------------------------------------------- +def _segments(name: str) -> list[str | int]: + """``attachments[*].actions[0].text`` → ``["attachments", "*", "actions", 0, "text"]``.""" + out: list[str | int] = [] + for part in name.split("."): + key, _, rest = part.partition("[") + if key: + out.append(key) + if rest: + for idx in _SEGMENT_RE.findall("[" + rest): + out.append("*" if idx == "*" else int(idx)) + return out + + +def _join(segs: Sequence[str | int]) -> str: + """The name of ``segs`` (the inverse of :func:`_segments`).""" + out = "" + for seg in segs: + if isinstance(seg, int) or seg == "*": + out += f"[{seg}]" + else: + out += f".{seg}" if out else str(seg) + return out + + +def input_name(key: str) -> str | None: + """A JSONPath key or path as a Codex path name; None when it has no equivalent. + + ``$['a']['b']`` → ``a.b``, ``$['d'][0]`` → ``d[0]``, ``$['x'][*]['y']`` → + ``x[*].y``, ``$.a.b`` → ``a.b``; a Codex name (``a.b``, ``x[*]``) is kept. + A bracketed name holding ``.``, ``[`` or ``]``, recursive descent + (``$..a``) and malformed paths have no equivalent. + """ + s = key.strip() + if s == "$" or s.startswith(("$.", "$[")): + s = s[1:] # the JSONPath root (``$filter`` is a field name) + segs: list[str | int] = [] + i, n = 0, len(s) + while i < n: + c = s[i] + if c == "[": + m = _BRACKET_RE.match(s, i) + if m is None: + return None + quoted = m.group(1) if m.group(1) is not None else m.group(2) + if quoted is not None: + if not quoted or any(ch in quoted for ch in ".[]"): + return None + segs.append(quoted) + else: + idx = m.group(3) + segs.append("*" if idx == "*" else int(idx)) + i = m.end() + continue + if c == ".": + i += 1 + elif segs: + return None # a name right after ``]`` + m = _NAME_RE.match(s, i) + if m is None: + return None + segs.append(m.group(0)) + i = m.end() + return _join(segs) or None + + +# -------------------------------------------------------------------------- +# reading and writing paths +# -------------------------------------------------------------------------- +def _set_path(obj: Any, name: str, value: Any) -> None: + """Set ``value`` at IS name ``name`` in ``obj``. + + ``a.b.c`` creates the objects on the way; ``a[*].b`` sets ``b`` on every + element of an existing list ``a`` (an absent list is not created); + ``a[*]`` sets every element, and turns an empty or absent ``a`` into + ``[value]`` (as langchain does); ``a[0].b`` addresses (and pads) one + element. + """ + segs = _segments(name) + if segs: + _set_segments(obj, segs, value) + + +def _set_segments(obj: Any, segs: list[str | int], value: Any) -> None: + seg, rest = segs[0], segs[1:] + if seg == "*": + if not isinstance(obj, list): + return + for i in range(len(obj)): + if rest: + _set_segments(obj[i], rest, value) + else: + obj[i] = copy.deepcopy(value) + return + if isinstance(seg, int): + if not isinstance(obj, list): + return + while len(obj) <= seg: + obj.append({} if rest and not isinstance(rest[0], int) else None) + if not rest: + obj[seg] = value + return + if not isinstance(obj[seg], (dict, list)): + obj[seg] = [] if isinstance(rest[0], int) else {} + _set_segments(obj[seg], rest, value) + return + if not isinstance(obj, dict): + return + if not rest: + obj[seg] = value + return + child = obj.get(seg) + nxt = rest[0] + if nxt == "*": + if rest == ["*"] and (child is None or child == []): + # langchain's apply_static_args: an empty (or absent) array + # becomes ``[value]`` instead of staying empty. + obj[seg] = [copy.deepcopy(value)] + elif isinstance(child, list): + _set_segments(child, rest, value) + return + if isinstance(nxt, int): + if not isinstance(child, list): + child = obj[seg] = [] + elif not isinstance(child, dict): + child = obj[seg] = {} + _set_segments(child, rest, value) + + +def _get_path(obj: Any, path: str) -> tuple[bool, Any]: + """``(found, value)`` of a ``{{x}}`` path (``x``, ``a.b``, ``$.a.b``) in ``obj``.""" + path = path.strip() + if path.startswith("$"): + path = path[1:].lstrip(".") + current = obj + for seg in _segments(path): + if isinstance(seg, int): + if not isinstance(current, list) or seg >= len(current): + return False, None + current = current[seg] + elif isinstance(current, dict) and seg in current: + current = current[seg] + else: + return False, None + return True, current + + +# -------------------------------------------------------------------------- +# schema editing +# -------------------------------------------------------------------------- +def _resolve_ref(root: dict[str, Any], node: Any) -> Any: + """Follow a local ``$ref``, inlining a copy so shared definitions stay intact.""" + if not isinstance(node, dict) or "$ref" not in node: + return node + ref = node["$ref"] + if not isinstance(ref, str) or not ref.startswith("#/"): + return node + target: Any = root + for part in ref[2:].split("/"): + if not isinstance(target, dict) or part not in target: + return node + target = target[part] + if not isinstance(target, dict): + return node + inlined = copy.deepcopy(target) + node.pop("$ref") + node.update({k: v for k, v in inlined.items() if k not in node}) + return node + + +def _unwrap_any_of(root: dict[str, Any], node: Any) -> Any: + """The first non-null ``anyOf`` branch of ``node`` (``$ref`` resolved), else ``node``.""" + if not isinstance(node, dict): + return node + for branch in node.get("anyOf") or []: + if isinstance(branch, dict) and branch.get("type") != "null": + return _resolve_ref(root, branch) + return node + + +def _navigate(root: dict[str, Any], segs: list[str | int]) -> dict[str, Any] | None: + """The schema node at ``segs``, nullable ``anyOf`` unwrapped (also at the leaf).""" + current: Any = _resolve_ref(root, root) + for seg in segs: + if not isinstance(current, dict): + return None + current = _unwrap_any_of(root, current) + if seg in ("*",) or isinstance(seg, int): + current = current.get("items") + else: + props = current.get("properties") + if not isinstance(props, dict) or seg not in props: + return None + current = props[seg] + current = _resolve_ref(root, current) + current = _unwrap_any_of(root, current) + return current if isinstance(current, dict) else None + + +def _parent_and_key( + schema: dict[str, Any], name: str +) -> tuple[dict[str, Any], str] | None: + """The object schema holding IS name ``name``'s leaf, and the leaf key.""" + segs = _segments(name) + if not segs or not isinstance(segs[-1], str): + return None # ``x[*]`` / ``x[0]``: the leaf is an array element + parent = _navigate(schema, segs[:-1]) if len(segs) > 1 else schema + if parent is None or not isinstance(parent.get("properties"), dict): + return None + return parent, segs[-1] + + +def _hide_static(schema: dict[str, Any], names: Sequence[str]) -> dict[str, Any]: + """Remove static fields (at any depth) from ``properties`` and ``required``. + + Returns a modified deep copy (``$ref`` on the way are inlined). + """ + if not names: + return schema + schema = copy.deepcopy(schema) + for name in names: + found = _parent_and_key(schema, name) + if found is None: + continue + parent, key = found + parent["properties"].pop(key, None) + if isinstance(parent.get("required"), list): + parent["required"] = [r for r in parent["required"] if r != key] + return schema + + +def _note_arguments( + schema: dict[str, Any], arguments: Mapping[str, str] +) -> dict[str, Any]: + """Keep ``{{x}}`` fields visible but optional, noting where they come from. + + The agent input's value wins at call time; when the input has none, the + model's value is sent (langchain leaves an unresolved field to the + model too). Returns a modified deep copy. + """ + if not arguments: + return schema + schema = copy.deepcopy(schema) + for name, path in arguments.items(): + found = _parent_and_key(schema, name) + if found is None: + continue + parent, key = found + node = parent["properties"].get(key) + if not isinstance(node, dict): + continue + note = ( + f"Set from the agent input '{path}' when it has a value; fill it " + "only if it does not." + ) + description = node.get("description") + node["description"] = f"{description} ({note})" if description else note + if isinstance(parent.get("required"), list): + parent["required"] = [r for r in parent["required"] if r != key] + return schema + + +def _without_fields(schema: dict[str, Any], names: list[str]) -> dict[str, Any]: + """``schema`` without the top-level fields of ``names`` (``a`` of ``a.b``).""" + tops = {n for n in names if "." not in n and "[" not in n} + if not tops or not isinstance(schema.get("properties"), dict): + return schema + out = copy.deepcopy(schema) + for name in tops: + out["properties"].pop(name, None) + if isinstance(out.get("required"), list): + out["required"] = [r for r in out["required"] if r not in tops] + return out + + +def reachable(schema: dict[str, Any], name: str) -> bool: + """True when ``schema`` has the field ``name`` (for ``x[*]`` / ``x[0]``: ``x``).""" + segs = _segments(name) + if not segs: + return False + schema = copy.deepcopy(schema) # navigating inlines ``$ref`` + if isinstance(segs[-1], str) and segs[-1] != "*": + found = _parent_and_key(schema, name) + return found is not None and found[1] in found[0]["properties"] + return _navigate(schema, segs[:-1]) is not None + + +# -------------------------------------------------------------------------- +# pre-filled arguments +# -------------------------------------------------------------------------- +@dataclass +class FixedArg: + """A pre-filled argument the model does not fill. + + One of: ``value`` (static), ``path`` (from the agent input), ``tokens`` + (a text template rendered from the agent input) or ``items`` (an array + built from other pre-filled arguments; a gap is None). + """ + + value: Any = None + path: str | None = None + tokens: list[Any] | None = None + items: list[FixedArg | None] | None = None + + @property + def from_input(self) -> bool: + """True for an agent-input value the model fills when the input has none.""" + return self.path is not None and self.tokens is None and self.items is None + + def resolve(self, agent_input: dict[str, Any] | None) -> tuple[bool, Any]: + """``(found, value)``: only an agent-input path can be not found.""" + if self.items is not None: + values: list[Any] = [] + for item in self.items: + found, value = ( + item.resolve(agent_input) if item is not None else (False, None) + ) + values.append(value if found else None) + return True, values + if self.tokens is not None: + from uipath.agent.utils.text_tokens import build_string_from_tokens + + return True, build_string_from_tokens(self.tokens, agent_input or {}) + if self.path is not None: + return _get_path(agent_input, self.path) + return True, copy.deepcopy(self.value) + + +def dedupe_parents(names: Iterable[str]) -> set[str]: + """``names`` without those under another name (``a.b`` when ``a`` is there). + + langchain's parent-wins rule, compared by path segment so ``user`` does + not swallow ``users``. + """ + segs = {name: _segments(name) for name in names} + keep: set[str] = set() + for name, mine in segs.items(): + if not any( + other != name and len(theirs) < len(mine) and mine[: len(theirs)] == theirs + for other, theirs in segs.items() + ): + keep.add(name) + return keep + + +def _variant(prop: Any) -> str: + variant = getattr(prop, "variant", None) + return str(getattr(variant, "value", variant) or type(prop).__name__) + + +def fixed_args_from_properties( + label: str, + argument_properties: Mapping[str, Any] | None, + *, + schema: dict[str, Any] | None = None, + strict: bool = False, +) -> dict[str, FixedArg]: + """agent.json ``argumentProperties`` as pre-filled arguments. + + Keys and argument paths may be JSONPath (``$['a']['b']``). Static, + argument (agent-input path), text-builder and array-builder properties + are supported (an array's items are its ``name[N]`` keys); a parent wins + over the paths under it, as in langchain. Any other property is left to + the model with a warning, or with ``strict`` an object builder is refused + (langchain's "Unsupported argument property type") unless a parent path + covers it. When ``schema`` has properties, a name it does not have is + dropped with a warning. + + Raises: + ValueError: ``strict`` and an object-builder property no parent covers. + """ + from uipath.agent.models.agent import ( + AgentToolArgumentArgumentProperties, + AgentToolStaticArgumentProperties, + AgentToolTextBuilderArgumentProperties, + ) + + fixed: dict[str, FixedArg] = {} + arrays: list[str] = [] + objects: dict[str, str] = {} # name → key, refused once deduped (strict) + for key, prop in (argument_properties or {}).items(): + name = input_name(key) + if name is None: + logger.warning( + "%s: argument property key '%s' is not a supported path; the " + "model fills it.", + label, + key, + ) + continue + variant = _variant(prop) + if isinstance(prop, AgentToolStaticArgumentProperties): + fixed[name] = FixedArg(value=prop.value) + elif isinstance(prop, AgentToolArgumentArgumentProperties): + path = input_name(prop.argument_path) + if path is None: + logger.warning( + "%s: argument property '%s' reads '%s', which is not a " + "supported agent input path; the model fills it.", + label, + key, + prop.argument_path, + ) + continue + fixed[name] = FixedArg(path=path) + elif isinstance(prop, AgentToolTextBuilderArgumentProperties): + fixed[name] = FixedArg(tokens=list(prop.tokens)) + elif variant == "arrayBuilder": + arrays.append(name) + elif variant == "objectBuilder" and strict: + objects[name] = key + else: + logger.warning( + "%s: argument property '%s' (%s) is not supported; the model fills it.", + label, + key, + variant, + ) + # Innermost arrays first, so an array item can itself be an array. + for name in sorted(arrays, key=lambda n: len(_segments(n)), reverse=True): + child = re.compile(rf"^{re.escape(name)}\[(\d+)\]$") + indexed = { + int(m.group(1)): arg + for other, arg in fixed.items() + if (m := child.match(other)) is not None + } + items: list[FixedArg | None] = [None] * (max(indexed) + 1 if indexed else 0) + for index, arg in indexed.items(): + items[index] = arg + fixed[name] = FixedArg(items=items) + if objects: + _refuse_objects(label, objects, fixed, arrays) + kept = dedupe_parents(fixed) + fixed = {name: arg for name, arg in fixed.items() if name in kept} + if isinstance(schema, dict) and schema.get("properties"): + for name in list(fixed): + if not reachable(schema, name): + logger.warning( + "%s: argument property '%s' is not in the tool's input " + "schema; ignored.", + label, + name, + ) + del fixed[name] + return fixed + + +def _refuse_objects( + label: str, + objects: Mapping[str, str], + fixed: Mapping[str, FixedArg], + arrays: Sequence[str], +) -> None: + """Refuse an object builder langchain would resolve (and fail on). + + That is one no parent path covers, or a direct item (``name[N]``) of an + array builder that is resolved: langchain's parent-wins dedup skips any + other. + """ + kept = dedupe_parents([*fixed, *objects]) + used = {name for name in arrays if name in kept} + grown = True + while grown: # an array item that is itself an array builder + items = {n for n in arrays if n not in used and _item_of(n) in used} + grown = bool(items) + used |= items + for name, key in objects.items(): + if name in kept or _item_of(name) in used: + raise ValueError( + f"{label}: argument property '{key}' is an objectBuilder; this " + "argument property type is unsupported." + ) + + +def _item_of(name: str) -> str | None: + """The array ``name`` is a direct item of (``x`` of ``x[0]``), else None.""" + segs = _segments(name) + if len(segs) > 1 and isinstance(segs[-1], int): + return _join(segs[:-1]) + return None + + +def fixed_args_from_code( + static_args: Mapping[str, Any] | None, + args_from_input: Mapping[str, str] | None, + *, + label: str | None = None, +) -> dict[str, FixedArg]: + """``static_args`` and ``args_from_input`` (field → ``{{a.b}}`` / ``a.b``) as pre-filled arguments. + + Fields and paths may be JSONPath (``$['a']['b']``). With a ``label``, a + field set in both mappings or a path with no Codex equivalent is refused; + without one, the field is kept as written and ``args_from_input`` wins. + + Raises: + ValueError: With ``label``, a field in both mappings or a bad path. + """ + + def name_of(raw: str, what: str) -> str: + name = input_name(str(raw)) + if name is None: + if label is not None: + raise ValueError(f"{label}: {what} '{raw}' is not a supported path.") + return str(raw) + return name + + fixed: dict[str, FixedArg] = { + name_of(name, "field"): FixedArg(value=value) + for name, value in (static_args or {}).items() + } + for name, path in (args_from_input or {}).items(): + field_name = name_of(name, "field") + if label is not None and field_name in fixed: + raise ValueError( + f"{label}: '{name}' is in both static_args and args_from_input." + ) + fixed[field_name] = FixedArg( + path=name_of(str(path).strip().strip("{}").strip(), "agent input path") + ) + return fixed + + +def with_fixed_args( + args: dict[str, Any], + fixed_args: Mapping[str, FixedArg], + agent_input: dict[str, Any] | None, +) -> dict[str, Any]: + """The model's arguments with the pre-filled values (they win), as JSON values. + + An agent-input value the input does not have leaves the model's value. + ``args`` is not changed. + """ + data = copy.deepcopy(args) + for name, fixed in fixed_args.items(): + found, value = fixed.resolve(agent_input) + if found: + _set_path(data, name, value) + return json.loads(json.dumps(data, default=str)) + + +def missing_from_input( + schema: dict[str, Any], + fixed_args: Mapping[str, FixedArg], + merged: dict[str, Any], +) -> list[str]: + """The agent-input fields ``schema`` requires that ``merged`` still lacks. + + That is, the agent input had no value and the model did not fill it + (:func:`fixed_args_schema` shows such a field as optional). A field under + an absent object is not reported: that object's own requirement decides. + Through ``[*]``, every element is checked (``x[*].y`` is reported when + any element of ``x`` lacks ``y``). + """ + missing: list[str] = [] + for name, fixed in fixed_args.items(): + segs = _segments(name) + if not fixed.from_input or not segs or segs[-1] == "*": + continue + if not isinstance(segs[-1], str): + continue # ``x[0]``: the leaf is an array element + found = _parent_and_key(copy.deepcopy(schema), name) + if found is None: + continue + parent, key = found + if key not in (parent.get("required") or []): + continue + if any( + isinstance(container, dict) and key not in container + for container in _values_at(merged, segs[:-1]) + ): + missing.append(name) + return missing + + +def _values_at(obj: Any, segs: Sequence[str | int]) -> list[Any]: + """The values at ``segs`` in ``obj`` (``*`` each element); absent ones are skipped.""" + if not segs: + return [obj] + seg, rest = segs[0], segs[1:] + if seg == "*": + if not isinstance(obj, list): + return [] + return [v for item in obj for v in _values_at(item, rest)] + if isinstance(seg, int): + if not isinstance(obj, list) or seg >= len(obj): + return [] + return _values_at(obj[seg], rest) + if not isinstance(obj, dict) or seg not in obj: + return [] + return _values_at(obj[seg], rest) + + +def missing_args_message(tool_name: str, missing: Sequence[str]) -> str: + """The tool error for required fields neither the agent input nor the model filled.""" + return ( + f"Invalid arguments for tool '{tool_name}': missing required " + f"argument(s): {', '.join(missing)}. The agent input has no value for " + "them; fill them in and call the tool again." + ) + + +def fixed_args_schema( + schema: dict[str, Any], fixed_args: Mapping[str, FixedArg] +) -> dict[str, Any]: + """The input schema the model sees. + + Static, text-builder and array-builder fields are removed at any depth + (``x[*]`` removes the array ``x``); agent-input fields stay visible but + optional, with a note, so the model can fill them when the agent input + has no value. ``x[0]`` stays visible (its value is still merged). + """ + if not fixed_args: + return schema + hidden: list[str] = [] + noted: dict[str, str] = {} + for name, fixed in fixed_args.items(): + if fixed.from_input: + noted[name] = str(fixed.path) + continue + segs = _segments(name) + if len(segs) > 1 and segs[-1] == "*" and isinstance(segs[-2], str): + hidden.append(_join(segs[:-1])) + else: + hidden.append(name) + return _note_arguments(_hide_static(schema, hidden), noted) + + +__all__ = [ + "FixedArg", + "dedupe_parents", + "fixed_args_from_code", + "fixed_args_from_properties", + "fixed_args_schema", + "input_name", + "missing_args_message", + "missing_from_input", + "reachable", + "with_fixed_args", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py new file mode 100644 index 00000000..3e2228b0 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py @@ -0,0 +1,107 @@ +"""User-facing agent definition for the Codex SDK integration. + +Developers export a :class:`UiPathCodexAgent` from their entrypoint module +(``main.py:agent``). The UiPath runtime owns the Codex run loop +(``AsyncCodex`` → ``thread_start`` → ``run``) so it can point Codex at the +UiPath LLM Gateway and map the result to a UiPath runtime result. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pydantic import BaseModel + +from .mcp import McpServer +from .tools import AnyUiPathTool + +__all__ = ["UiPathCodexAgent", "CodexAgent"] + + +@dataclass +class UiPathCodexAgent: + """Declarative Codex agent executed by the UiPath Codex SDK runtime. + + Args: + model: Model id as listed by the tenant's discovery endpoint, e.g. + ``"gpt-5.6-terra"`` or ``"gpt-4.1-2025-04-14"``. The runtime starts + a local gateway shim and routes this model through the UiPath LLM + Gateway. + prompt: User-message template rendered with ``str.format`` from the + validated input fields (e.g. ``"Tell me a joke about {topic}"``). + When the input has no fields to interpolate, it is used verbatim. + input_schema: Optional Pydantic model describing structured input. When + set, input is validated against it before the prompt is rendered. + output_schema: Optional Pydantic model describing structured output. + When set, the runtime requests native structured output from Codex. + base_instructions: Optional system/base instructions for the thread. + sandbox: Codex sandbox preset name (``"read-only"``, + ``"workspace-write"``, ``"full-access"``). Defaults to read-only — + a plain LLM agent never needs to touch the filesystem. Note that + ``"workspace-write"`` relies on Codex's OS-level sandbox (Linux + namespaces/landlock); in restricted containers that lack those + capabilities, use ``"full-access"`` (the run is already contained + by the robot/host). + approval_mode: How Codex handles escalated permission requests: + ``"deny_all"`` (default here — never escalates, no reviewer call) + or ``"auto_review"`` (Codex's own default — an automated reviewer + decides, which issues an extra LLM call using a ``codex-auto-review`` + model that the UiPath gateway may not route). We default to + ``"deny_all"``; the public async SDK exposes no approval-handler + hook, and the codex process auto-accepts command/file approvals, so + ``"deny_all"`` (with a ``"full-access"`` sandbox for tool/coding + agents) is the reliable posture on the gateway. + mcp_servers: MCP servers to expose to Codex as tools, keyed by server + name. Values are ``StdioMcpServer`` (local subprocess) or + ``RemoteMcpServer`` (streamable-HTTP endpoint). + codex_bin: Path to a specific ``codex`` binary to run instead of the + one bundled with ``openai-codex``. Falls back to the + ``UIPATH_CODEX_BIN`` env var, then the bundled binary. Use this to + target a newer stable CLI (e.g. rust-v0.153.4). + name: Display name used in the runtime graph. + tools: UiPath tools, served to Codex as the ``uipath_tools`` MCP + server. **Durable** tools + (:func:`~uipath_codex_sdk.uipath_process_tool`, + :func:`~uipath_codex_sdk.uipath_agent_tool`) start an Orchestrator + job and suspend the run until the job finishes; the job output + then becomes the result of the model's original call. + **Immediate** tools + (:func:`~uipath_codex_sdk.uipath_integration_tool`, or any + :class:`~uipath_codex_sdk.UiPathTool`) are answered as soon as + their work is done and never suspend the run. + codex_home: ``CODEX_HOME`` for the codex process. With durable tools + it defaults to a run-scoped directory under the runtime dir + (``__uipath/codex_home``), reset together with the state database + on a fresh local run; ``~/.codex`` (config.toml, AGENTS.md) is then + not read. Without durable tools it defaults to codex's own + resolution (``$CODEX_HOME``, else ``~/.codex``), as before. + hermetic: Pass ``--config`` overrides that stop codex from calling the + ChatGPT backend (``features.plugins=false`` and an unreachable + ``chatgpt_base_url``). ``None`` (default) turns them on only for + agents with durable tools. + durable_tool_instructions: Tag durable tool descriptions with + ``[long-running]`` and add one developer instruction about them + (call independent ones together; never retry one that is pending). + durable_settle_timeout: Seconds a suspension waits for non-durable + sibling calls of the same model response to finish before Codex is + stopped anyway. + """ + + model: str + prompt: str | None = None + input_schema: type[BaseModel] | None = None + output_schema: type[BaseModel] | None = None + base_instructions: str | None = None + sandbox: str = "read-only" + approval_mode: str = "deny_all" + mcp_servers: dict[str, McpServer] | None = field(default=None) + codex_bin: str | None = None + name: str = "agent" + tools: list[AnyUiPathTool] | None = field(default=None) + codex_home: str | None = None + hermetic: bool | None = None + durable_tool_instructions: bool = True + durable_settle_timeout: float = 120.0 + + +CodexAgent = UiPathCodexAgent diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/attachment_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/attachment_tools.py new file mode 100644 index 00000000..e936f38a --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/attachment_tools.py @@ -0,0 +1,647 @@ +"""Analyze Files: the low-code internal tool that reads job attachments. + +The model passes JobAttachment objects (already resolved by the tool host +against the run's known attachments) and an ``analysisTask``. As in +LangChain, the tool downloads each file (30 MB at most, a larger one fails the +run), sends it with the task to the agent's model in one non-streaming +Responses call through the UiPath LLM Gateway, and answers +``{"analysisResult": }``. + +Spans, as in low-code agents: ``Tool call - `` (``toolType`` +``Internal``) → ```` (the internal tool) → ``LLM call`` with the files +sent as ``attachments``. + +Typical use:: + + from uipath_codex_sdk import UiPathAttachment, UiPathCodexAgent + from uipath_codex_sdk import uipath_analyze_files_tool + + class Input(BaseModel): + invoice: UiPathAttachment + question: str + + agent = UiPathCodexAgent( + input_schema=Input, + prompt="Answer {question} about the attached invoice.", + tools=[uipath_analyze_files_tool()], + ) +""" + +from __future__ import annotations + +import base64 +import copy +import json +import logging +import mimetypes +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, Mapping + +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.trace import Status, StatusCode +from pydantic import ValidationError +from uipath.runtime.errors import UiPathErrorCategory + +from ._fixed_args import ( + FixedArg, + fixed_args_from_properties, + fixed_args_schema, + missing_args_message, + missing_from_input, + with_fixed_args, +) +from .attachments import ( + Attachment, + dump_attachment, + span_attachments, + to_attachment, +) +from .integration_tools import _lowcode_tool_name +from .runtime.errors import UiPathCodexErrorCode, UiPathCodexRuntimeError +from .tools import ( + ToolContext, + UiPathToolError, + _validate_against_schema, + sanitize_tool_name, +) + +if TYPE_CHECKING: + from .runtime.mocking import MockSpec + +logger = logging.getLogger(__name__) + +DEFAULT_NAME = "Analyze Files" +DEFAULT_DESCRIPTION = ( + "Analyze one or more files with an LLM to extract, synthesize, or answer " + "queries about their content." +) +#: LangChain's system message for the analysis call. +ANALYZE_FILES_SYSTEM_MESSAGE = ( + "Process the provided files to complete the given task. " + "Analyze the files contents thoroughly to deliver an accurate response " + "based on the extracted information." +) +NO_ATTACHMENTS_RESULT = "No attachments provided to analyze." +#: ``properties.toolType`` of the low-code resource. +ANALYZE_FILES_TOOL_TYPE = "analyze-attachments" +#: LangChain's per-file limit for LLM payloads. +MAX_FILE_BYTES = 30 * 1024 * 1024 +IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) +#: Types LangChain converts first (TIFF pages to PNG); not supported here yet. +UNSUPPORTED_MIME_TYPES = frozenset({"image/tiff", "image/tif"}) +SPAN_LLM_CALL = "llmCall" +#: Low-code internal tool spans are ``toolCall`` spans named after the tool. +SPAN_INTERNAL_TOOL = "toolCall" + + +def analyze_files_input_schema() -> dict[str, Any]: + """The canonical Analyze Files arguments (``attachments``, ``analysisTask``).""" + return { + "type": "object", + "required": ["attachments", "analysisTask"], + "properties": { + "attachments": { + "type": "array", + "items": {"$ref": "#/definitions/job-attachment"}, + "description": ( + "Array of files, documents, images, or other attachments to process" + ), + }, + "analysisTask": { + "type": "string", + "description": ( + "The task, question, or instruction for processing the files" + ), + }, + }, + "definitions": { + "job-attachment": { + "type": "object", + "required": ["ID"], + "x-uipath-resource-kind": "JobAttachment", + "properties": { + "ID": { + "type": "string", + "description": "Orchestrator attachment key", + }, + "FullName": {"type": "string", "description": "File name"}, + "MimeType": { + "type": "string", + "description": "The MIME type of the content, such as " + "'application/json' or 'image/png'", + }, + }, + } + }, + } + + +def sanitize_filename(filename: str) -> str: + """A file name the model providers accept (LangChain's rule).""" + if not filename or filename.isspace(): + return "document" + cleaned = re.sub(r"[^a-zA-Z0-9\s\-\(\)\[\]]", "-", filename) + cleaned = re.sub(r"\s+", " ", cleaned) + cleaned = re.sub(r"-+", "-", cleaned).strip(" -") + return cleaned or "document" + + +def _mb(size: int, decimals: int = 1) -> str: + return f"{size / (1024 * 1024):.{decimals}f} MB" + + +def _too_large(detail: str) -> UiPathCodexRuntimeError: + return UiPathCodexRuntimeError( + UiPathCodexErrorCode.FILE_ERROR, + "File attachment too large", + detail, + UiPathErrorCategory.USER, + ) + + +def _uipath_client() -> Any: + """The UiPath platform client (a seam for tests).""" + from uipath.platform import UiPath + + return UiPath() + + +def _http_client() -> Any: + """httpx client for the blob download (a seam for tests).""" + import httpx + from uipath._utils._ssl_context import get_httpx_client_kwargs + + return httpx.AsyncClient(**get_httpx_client_kwargs()) + + +async def _gateway_client(model: str) -> Any: + """The LLM Gateway client of ``model`` (a seam for tests).""" + import asyncio + + from uipath.llm_client import UiPathHttpxAsyncClient + from uipath.llm_client.settings import get_default_client_settings + + from .runtime.gateway.upstream import UPSTREAM_TIMEOUT, build_api_config + + settings = await asyncio.to_thread(get_default_client_settings) + return UiPathHttpxAsyncClient( + model_name=model, + client_settings=settings, + api_config=build_api_config(), + timeout=UPSTREAM_TIMEOUT, + logger=logger, + ) + + +def _trace_headers() -> dict[str, str]: + """Headers that let the LLM Gateway add its ``Model run`` span.""" + try: + from uipath.platform.chat.llm_trace_context import ( + build_trace_context_headers, + ) + + from .runtime.telemetry import TRACE_SOURCE + + return build_trace_context_headers(extra_baggage=[TRACE_SOURCE]) + except Exception: + logger.debug("could not build trace context headers", exc_info=True) + return {} + + +def _output_text(response: Any) -> str: + """The text of a Responses API answer.""" + if not isinstance(response, dict): + return "" + if isinstance(response.get("output_text"), str): + return response["output_text"] + parts: list[str] = [] + for item in response.get("output") or []: + if not isinstance(item, dict) or item.get("type") != "message": + continue + for content in item.get("content") or []: + if isinstance(content, dict) and content.get("type") == "output_text": + parts.append(str(content.get("text") or "")) + return "".join(parts) + + +def _gateway_error(response: Any) -> str: + try: + body = response.json() + except Exception: + return (getattr(response, "text", "") or "")[:500] + error = body.get("error") if isinstance(body, dict) else None + if isinstance(error, dict): + return str(error.get("message") or error) + return json.dumps(body)[:500] + + +@dataclass +class UiPathAnalyzeFilesTool: + """An immediate tool that analyzes job attachments with the agent's model. + + Prefer :func:`uipath_analyze_files_tool` or + :func:`analyze_files_tool_from_resource` over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + input_json_schema: Arguments: ``attachments`` (JobAttachments) and + ``analysisTask``. + model: Model of the analysis call; None means the agent's model. + timeout_sec: Host-enforced timeout; None means 300 seconds. + fixed_args: Arguments pre-filled from the agent input or static + values (agent.json ``argumentProperties``): static ones are + hidden from the model, agent-input ones stay visible but + optional (the model's value is used when the input has none). + display_name: Name of the internal tool span (the resource name). + max_file_bytes: Per-file size limit; a larger file fails the run. + mock_name: Name the tool is simulated by in evals; None means the + display name. + mock_output_schema: Output schema of a simulated call (the + resource's ``outputSchema``); None means ``{"analysisResult"}``. + """ + + #: The analysis is mocked inside ``call`` (under its span), not by the host. + mocks_in_call: ClassVar[bool] = True + + name: str + description: str + input_json_schema: dict[str, Any] = field( + default_factory=analyze_files_input_schema + ) + model: str | None = None + timeout_sec: float | None = None + fixed_args: dict[str, FixedArg] = field(default_factory=dict) + display_name: str | None = None + max_file_bytes: int = MAX_FILE_BYTES + kind: str = "internal" + tool_type: str = "Internal" + mock_name: str | None = None + mock_output_schema: dict[str, Any] | None = None + + @property + def input_schema(self) -> dict[str, Any]: + """The arguments as the model sees them (pre-filled ones hidden).""" + return fixed_args_schema(self.input_json_schema, self.fixed_args) + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + _validate_against_schema(self.input_schema, args) + return dict(args) + + @property + def prefilled_schema(self) -> dict[str, Any]: + """The arguments, pre-filled fields included (the host resolves their attachments).""" + return self.input_json_schema + + def prefilled_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled values, unvalidated. + + The host checks the job attachments of these (LangChain's order). + """ + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, agent_input) + + def span_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The ``Tool call`` span's input: the arguments with the pre-filled values.""" + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, getattr(ctx, "agent_input", None)) + + async def call(self, args: dict[str, Any], ctx: ToolContext) -> str: + args = ( + with_fixed_args(args, self.fixed_args, ctx.agent_input) + if self.fixed_args + else copy.deepcopy(args) + ) + # An agent-input field the input has no value for keeps the model's + # value (langchain's StaticArgsHandler); a required one neither + # filled fails the call before the mock, as langchain's schema check. + missing = missing_from_input(self.input_json_schema, self.fixed_args, args) + if missing: + raise UiPathToolError(missing_args_message(self.name, missing)) + # Attachment validation runs before the mock (langchain's + # job_attachment_wrapper); the argument checks and the no-files answer + # are inside it (langchain's mocked tool_fn), so a simulated call is + # always answered by the mocker. + files = self._files(args.get("attachments"), ctx) + + from .runtime.mocking import mark_simulated, mocked_text, wrap + from .runtime.telemetry import _json, fail_span, start_internal_tool_span + + async def analyze(**_kwargs: Any): # the part a simulated call skips + task = args.get("analysisTask") + if not isinstance(task, str) or not task.strip(): + raise UiPathToolError("Argument 'analysisTask' is not available") + if "attachments" not in args: + raise UiPathToolError("Argument 'attachments' is not available") + if not files: + return {"analysisResult": NO_ATTACHMENTS_RESULT} + parts = [await self._file_part(f) for f in files] + text = await self._analyze(task, parts, files, ctx.model) + return {"analysisResult": text} + + arguments = dict(args) + if "attachments" in args: + arguments["attachments"] = [dump_attachment(f) for f in files] + span = start_internal_tool_span( + self.display_name or self.name, + arguments=arguments, + tool_type=self.tool_type, + ) + with trace.use_span(span, end_on_exit=False): + try: + result = await wrap(self.mock_spec, analyze)(**arguments) + except BaseException as e: + fail_span(span, e) + span.end() + raise + mark_simulated(span) + span.set_attribute("output", _json(result)) + span.set_attribute("output.mime_type", "application/json") + span.set_status(Status(StatusCode.OK)) + span.end() + return mocked_text(result) + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's: the resource's output schema, else ``analysisResult``.""" + from .runtime.mocking import MockSpec, mock_name_of + + return MockSpec( + name=mock_name_of(self), + description=self.description, + input_schema=self.input_schema, + output_schema=copy.deepcopy(self.mock_output_schema or {}) + or { + "type": "object", + "properties": {"analysisResult": {"type": "string"}}, + "required": ["analysisResult"], + }, + example_calls=[], + ) + + def _files(self, value: Any, ctx: ToolContext) -> list[Attachment]: + items = value if isinstance(value, list) else [value] if value else [] + files: list[Attachment] = [] + for item in items: + attachment = to_attachment(item) + if attachment is None and isinstance(item, dict) and ctx.attachments: + # A pre-filled argument the host did not resolve. + known = ctx.attachments.get(str(item.get("ID", item.get("id")))) + attachment = known + if attachment is None: + raise UiPathToolError( + f"Tool '{self.name}' got an attachment that is not a valid " + "JobAttachment; pass the JobAttachment objects you were given." + ) + files.append(attachment) + return files + + async def _file_part(self, attachment: Attachment) -> dict[str, Any]: + """A Responses content part with the file's bytes (base64).""" + mime = ( + attachment.mime_type + or mimetypes.guess_type(attachment.full_name)[0] + or "application/octet-stream" + ).lower() + if mime in UNSUPPORTED_MIME_TYPES: + raise UiPathToolError( + f"Attachment '{attachment.full_name}' is a {mime} file, which " + f"tool '{self.name}' cannot analyze yet." + ) + data = await self._download(attachment) + url = f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + if mime in IMAGE_MIME_TYPES: + return {"type": "input_image", "image_url": url} + return { + "type": "input_file", + "filename": sanitize_filename(attachment.full_name), + "file_data": url, + } + + async def _download(self, attachment: Attachment) -> bytes: + try: + info = await _uipath_client().attachments.get_blob_file_access_uri_async( + key=attachment.id + ) + except Exception as e: + raise UiPathToolError( + f"Could not access attachment '{attachment.full_name}' " + f"({attachment.id}): {e}" + ) from e + cap = self.max_file_bytes + async with _http_client() as http: + async with http.stream("GET", info.uri) as response: + if response.status_code >= 400: + raise UiPathToolError( + f"Could not download attachment '{attachment.full_name}' " + f"({attachment.id}): HTTP {response.status_code}" + ) + length = response.headers.get("content-length") + if length and length.isdigit() and cap > 0 and int(length) > cap: + raise _too_large( + f"File '{attachment.full_name}' is {_mb(int(length))}, which " + f"exceeds the {_mb(cap, 0)} limit for Agent LLM payloads." + ) + buffer = bytearray() + async for chunk in response.aiter_bytes(): + buffer.extend(chunk) + if cap > 0 and len(buffer) > cap: + raise _too_large( + f"File '{attachment.full_name}' exceeds the " + f"{_mb(cap, 0)} limit for LLM payloads (downloaded " + f"{_mb(len(buffer))} so far)." + ) + return bytes(buffer) + + async def _analyze( + self, + task: str, + parts: list[dict[str, Any]], + files: list[Attachment], + agent_model: str | None, + ) -> str: + """One non-streaming Responses call under an ``LLM call`` span.""" + from .runtime.gateway.upstream import UPSTREAM_PATH + from .runtime.telemetry import _json, get_tracer, span_attributes + + model = self.model or agent_model + if not model: + raise UiPathToolError(f"Tool '{self.name}' has no model to analyze with.") + span = get_tracer().start_span( + "LLM call", + attributes=span_attributes( + SPAN_LLM_CALL, + input=task, + model=model, + attachments=span_attachments(files, "in"), + ), + ) + body = { + "model": model, + "instructions": ANALYZE_FILES_SYSTEM_MESSAGE, + "input": [ + { + "role": "user", + "content": [{"type": "input_text", "text": task}, *parts], + } + ], + "stream": False, + "store": False, + } + token = context_api.attach(trace.set_span_in_context(span)) + try: + headers = {"content-type": "application/json", **_trace_headers()} + client = await _gateway_client(model) + try: + response = await client.request( + "POST", UPSTREAM_PATH, headers=headers, content=json.dumps(body) + ) + finally: + await client.aclose() + span.set_attribute("http.status_code", response.status_code) + if response.status_code >= 400: + raise UiPathToolError( + f"Tool '{self.name}' failed: the model returned " + f"{response.status_code}: {_gateway_error(response)}" + ) + payload = response.json() + text = _output_text(payload) + span.set_attribute("output", _json(text)) + span.set_attribute("content", _json(text)) + usage = payload.get("usage") if isinstance(payload, dict) else None + if usage: + span.set_attribute("usage", _json(usage)) + span.set_status(Status(StatusCode.OK)) + return text + except BaseException as e: + span.set_status(Status(StatusCode.ERROR, str(e) or type(e).__name__)) + span.set_attribute("error", str(e) or type(e).__name__) + raise + finally: + context_api.detach(token) + span.end() + + +def uipath_analyze_files_tool( + name: str = DEFAULT_NAME, + *, + description: str | None = None, + model: str | None = None, + tool_name: str | None = None, + input_schema: dict[str, Any] | None = None, + timeout_sec: float | None = None, +) -> UiPathAnalyzeFilesTool: + """Declare the Analyze Files tool. + + Args: + name: Display name (the internal tool span); the model-facing name is + this sanitized unless ``tool_name`` is given. + description: What the tool does, for the model. + model: Model of the analysis call; defaults to the agent's model. + tool_name: Model-facing tool name. + input_schema: Argument schema; defaults to the canonical + ``attachments`` + ``analysisTask``. + timeout_sec: Host-enforced timeout; None means 300 seconds. + """ + return UiPathAnalyzeFilesTool( + name=sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name), + description=description or DEFAULT_DESCRIPTION, + input_json_schema=dict(input_schema or analyze_files_input_schema()), + model=model, + timeout_sec=timeout_sec, + display_name=name, + ) + + +def internal_tool_resource(resource: Any, tool_type: str) -> tuple[Any, str]: + """An enabled ``AgentInternalToolResourceConfig`` of ``tool_type``, and its label. + + A dict is validated first; its ``type`` may be lowercase (``internal``, as + Agent Builder writes it: only ``AgentDefinition`` normalises it). + + Raises: + ValueError: The resource does not validate, is disabled, or is another + internal tool. + """ + from uipath.agent.models.agent import AgentInternalToolResourceConfig + + if isinstance(resource, Mapping): + data = dict(resource) + data.setdefault("$resourceType", "tool") + if isinstance(data.get("type"), str) and data["type"].lower() == "internal": + data["type"] = "Internal" + try: + resource = AgentInternalToolResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid internal tool resource: {e}") from e + label = f"Tool resource '{resource.name}'" + if resource.is_enabled is False: + raise ValueError(f"{label} is disabled; filter it out before building tools.") + kind = getattr(getattr(resource, "properties", None), "tool_type", None) + kind = getattr(kind, "value", kind) + if kind != tool_type: + raise ValueError(f"{label} is a '{kind}' internal tool, not '{tool_type}'.") + return resource, label + + +def analyze_files_tool_from_resource( + resource: Any, + *, + tool_name: str | None = None, + model: str | None = None, + timeout_sec: float | None = None, +) -> UiPathAnalyzeFilesTool: + """The Analyze Files tool from a low-code internal ``tool`` resource. + + Args: + resource: An ``AgentInternalToolResourceConfig``, or the resource dict + as found in a low-code ``agent.json`` (``"$resourceType": "tool"``, + ``"type": "Internal"``, ``properties.toolType`` + ``"analyze-attachments"``). + tool_name: Model-facing name; defaults to the resource name sanitized. + model: Model of the analysis call; defaults to the agent's model. + timeout_sec: Host-enforced timeout; None means 300 seconds. + + Raises: + ValueError: The resource is disabled, is another internal tool, or does + not validate. + """ + resource, label = internal_tool_resource(resource, ANALYZE_FILES_TOOL_TYPE) + fixed = fixed_args_from_properties(label, resource.argument_properties) + schema = resource.input_schema or analyze_files_input_schema() + return UiPathAnalyzeFilesTool( + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=resource.description or DEFAULT_DESCRIPTION, + input_json_schema=dict(schema), + model=model, + timeout_sec=timeout_sec, + fixed_args=fixed, + display_name=resource.name, + mock_name=resource.name, + mock_output_schema=copy.deepcopy(resource.output_schema) + if isinstance(resource.output_schema, dict) + and resource.output_schema.get("properties") + else None, + ) + + +__all__ = [ + "ANALYZE_FILES_SYSTEM_MESSAGE", + "MAX_FILE_BYTES", + "UiPathAnalyzeFilesTool", + "analyze_files_input_schema", + "analyze_files_tool_from_resource", + "fixed_args_from_properties", + "internal_tool_resource", + "uipath_analyze_files_tool", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/attachments.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/attachments.py new file mode 100644 index 00000000..9b5806e5 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/attachments.py @@ -0,0 +1,464 @@ +"""Job attachments: Orchestrator files a run receives, passes on and returns. + +As in LangChain ReAct agents, the model never opens a file itself. It sees +each attachment as a JobAttachment object (``{"ID", "FullName", "MimeType"}``) +and hands it to the tools that accept attachments (Analyze Files, process and +agent tools). The run keeps the attachments it knows: those of its input and +those tools returned. A tool argument naming an unknown ``ID`` is answered +with an error instead of running the tool, and an attachment field of the +agent output may only name a known one. + +Attachment fields are found in JSON schemas by any of: + +* ``"$ref": "#/definitions/job-attachment"`` (the platform schema marker, + ``uipath.runtime.schema.transform_attachments``); +* ``"x-uipath-resource-kind": "JobAttachment"``; +* ``"title": "UiPathAttachment"`` (a Pydantic model's own schema). +""" + +from __future__ import annotations + +import copy +import json +import logging +import uuid +from typing import Any, Iterable, Iterator + +from pydantic import BaseModel, ValidationError +from uipath.platform.attachments import Attachment + +logger = logging.getLogger(__name__) + +#: The attachment type to use in input and output models (the platform's own). +UiPathAttachment = Attachment + +ATTACHMENT_TITLE = "UiPathAttachment" +JOB_ATTACHMENT_DEFINITION = "job-attachment" +RESOURCE_KIND_KEY = "x-uipath-resource-kind" +RESOURCE_KIND = "JobAttachment" + +#: A path into a JSON value: property names, and ``"*"`` for every list item. +Path = tuple[str, ...] + +_MAX_DEPTH = 32 + + +# --- schema discovery --------------------------------------------------------- + + +def _resolve_ref(ref: str, root: dict[str, Any]) -> Any: + if not ref.startswith("#/"): + return None + node: Any = root + for part in ref[2:].split("/"): + if not isinstance(node, dict): + return None + node = node.get(part.replace("~1", "/").replace("~0", "~")) + return node + + +def is_attachment_schema(node: Any, root: dict[str, Any] | None = None) -> bool: + """True for a schema node that describes one job attachment.""" + if not isinstance(node, dict): + return False + ref = node.get("$ref") + if isinstance(ref, str): + if ref.rsplit("/", 1)[-1] == JOB_ATTACHMENT_DEFINITION: + return True + if root is not None: + target = _resolve_ref(ref, root) + if target is not node and is_attachment_schema(target): + return True + return ( + node.get(RESOURCE_KIND_KEY) == RESOURCE_KIND + or node.get("title") == ATTACHMENT_TITLE + ) + + +def attachment_paths(schema: Any) -> list[Path]: + """Paths of the attachment fields of a JSON schema (``$ref`` followed). + + ``("invoice",)`` is a property, ``("files", "*")`` every item of a list. + Nullable and other ``anyOf``/``oneOf``/``allOf`` branches share the path. + """ + if not isinstance(schema, dict): + return [] + root = schema + found: list[Path] = [] + + def walk(node: Any, path: Path, refs: frozenset[str]) -> None: + if not isinstance(node, dict) or len(path) > _MAX_DEPTH: + return + if is_attachment_schema(node, root): + if path not in found: + found.append(path) + return + ref = node.get("$ref") + if isinstance(ref, str) and ref not in refs: + walk(_resolve_ref(ref, root), path, refs | {ref}) + props = node.get("properties") + if isinstance(props, dict): + for name, sub in props.items(): + walk(sub, (*path, str(name)), refs) + items = node.get("items") + if isinstance(items, dict): + walk(items, (*path, "*"), refs) + for key in ("anyOf", "oneOf", "allOf"): + branches = node.get(key) + if isinstance(branches, list): + for branch in branches: + walk(branch, path, refs) + + walk(schema, (), frozenset()) + return found + + +def path_text(path: Path) -> str: + """JSONPath-like text of a path (``$.files[*]``), for messages.""" + out = "$" + for part in path: + out += "[*]" if part == "*" else f".{part}" + return out + + +# --- values at paths ---------------------------------------------------------- + + +def _values_at(data: Any, path: Path) -> Iterator[Any]: + if not path: + yield data + return + head, rest = path[0], path[1:] + if head == "*": + if isinstance(data, list): + for item in data: + yield from _values_at(item, rest) + elif isinstance(data, dict) and head in data: + yield from _values_at(data[head], rest) + + +def values_at(data: Any, paths: Iterable[Path]) -> list[Any]: + """The values found at ``paths`` (missing and null ones skipped).""" + return [v for p in paths for v in _values_at(data, p) if v] + + +def _replace_at(data: Any, path: Path, fn: Any) -> Any: + """``data`` with ``fn(value)`` at ``path`` (``data`` is changed in place).""" + if not path: + return fn(data) + head, rest = path[0], path[1:] + if head == "*": + if isinstance(data, list): + for i, item in enumerate(data): + data[i] = _replace_at(item, rest, fn) + elif isinstance(data, dict) and head in data: + data[head] = _replace_at(data[head], rest, fn) + return data + + +def _coerce_json(value: Any) -> Any: + """A JSON object or list the model sent as a string, parsed.""" + if isinstance(value, str) and value.strip()[:1] in ("{", "["): + try: + return json.loads(value) + except ValueError: + return value + return value + + +def dump_attachment(attachment: Attachment) -> dict[str, Any]: + """The wire shape ``{"ID", "FullName", "MimeType"[, "Metadata"]}``.""" + return attachment.model_dump(by_alias=True, mode="json", exclude_none=True) + + +def to_attachment(value: Any) -> Attachment | None: + """``value`` as an :class:`Attachment`, or None when it is not one.""" + if isinstance(value, Attachment): + return value + if isinstance(value, BaseModel): + value = value.model_dump(by_alias=True) + try: + return Attachment.model_validate(value) + except ValidationError: + return None + + +def not_found_message(attachment_id: str) -> str: + """LangChain's text for an unknown attachment ID (a tool result).""" + return ( + f"Could not find JobAttachment with ID='{attachment_id}'. " + "Try invoking the tool again and please make sure that you pass " + "valid JobAttachment IDs associated with existing JobAttachments in the " + "current context." + ) + + +def _id_of(value: Any) -> str | None: + if isinstance(value, dict): + raw = value.get("ID", value.get("id")) + return None if raw is None else str(raw) + return None + + +def _canonical_id(raw: str) -> str | None: + try: + return str(uuid.UUID(raw)) + except (ValueError, AttributeError, TypeError): + return None + + +# --- the run's known attachments ---------------------------------------------- + + +class AttachmentStore: + """The attachments a run knows, by ID: its input's and tools' results.""" + + def __init__(self, attachments: Iterable[Attachment] = ()) -> None: + self._known: dict[str, Attachment] = {} + self.changed = False + for attachment in attachments: + self.register(attachment) + self.changed = False + + def __len__(self) -> int: + return len(self._known) + + def __contains__(self, attachment_id: object) -> bool: + return ( + isinstance(attachment_id, str) + and _canonical_id(attachment_id) in self._known + ) + + @property + def attachments(self) -> list[Attachment]: + return list(self._known.values()) + + def get(self, attachment_id: str) -> Attachment | None: + key = _canonical_id(attachment_id) + return self._known.get(key) if key is not None else None + + def register(self, attachment: Attachment) -> None: + key = str(attachment.id) + if self._known.get(key) != attachment: + self._known[key] = attachment + self.changed = True + + def harvest(self, paths: Iterable[Path], data: Any) -> list[Attachment]: + """Register the attachments found at ``paths`` of ``data``; return them. + + A value that is not a valid attachment is skipped. + """ + found: list[Attachment] = [] + for value in values_at(data, list(paths)): + attachment = to_attachment(_coerce_json(value)) + if attachment is None: + logger.warning("Ignoring an invalid job attachment in a tool result") + continue + self.register(attachment) + found.append(attachment) + return found + + def resolve( + self, paths: Iterable[Path], args: dict[str, Any] + ) -> tuple[dict[str, Any], list[str]]: + """``args`` with each attachment at ``paths`` replaced by the known one. + + The model may pass ``{"ID": ...}`` only (or the object as a JSON + string). Returns the new arguments and one error per unknown or + invalid ID (LangChain's text); ``args`` is not changed. + """ + paths = list(paths) + if not paths: + return args, [] + result = copy.deepcopy(args) + errors: list[str] = [] + + def replace(value: Any) -> Any: + value = _coerce_json(value) + raw = _id_of(value) + if raw is None: + return value + known = self.get(raw) + if known is None: + errors.append(not_found_message(raw)) + return value + return dump_attachment(known) + + for path in paths: + result = _replace_at(result, path, replace) + return result, errors + + # --- persistence (suspend/resume) -------------------------------------- + + def to_json(self) -> list[dict[str, Any]]: + return [dump_attachment(a) for a in self._known.values()] + + @classmethod + def from_json(cls, data: Any) -> "AttachmentStore": + items = data if isinstance(data, list) else [] + return cls(a for a in (to_attachment(v) for v in items) if a is not None) + + +# --- model instances ------------------------------------------------------------ + + +def model_attachments(value: Any) -> list[Attachment]: + """Every :class:`Attachment` inside a validated model (fields, lists, dicts).""" + found: list[Attachment] = [] + + def walk(node: Any, depth: int) -> None: + if depth > _MAX_DEPTH: + return + if isinstance(node, Attachment): + found.append(node) + elif isinstance(node, BaseModel): + for name in type(node).model_fields: + walk(getattr(node, name, None), depth + 1) + elif isinstance(node, (list, tuple, set)): + for item in node: + walk(item, depth + 1) + elif isinstance(node, dict): + for item in node.values(): + walk(item, depth + 1) + + walk(value, 0) + return found + + +def model_has_attachments(model_type: type[BaseModel] | None) -> bool: + if model_type is None: + return False + try: + return bool(attachment_paths(model_type.model_json_schema())) + except Exception: + return False + + +class AttachmentTicket(dict): + """An attachment in a prompt: formats as compact JSON, indexes like a dict.""" + + def __format__(self, spec: str) -> str: + return format(json.dumps(self, separators=(",", ":")), spec) + + def __str__(self) -> str: + return json.dumps(self, separators=(",", ":")) + + +class _TicketList(list): + def __format__(self, spec: str) -> str: + return format(json.dumps(self, separators=(",", ":")), spec) + + def __str__(self) -> str: + return json.dumps(self, separators=(",", ":")) + + +def ticket(attachment: Attachment) -> AttachmentTicket: + data = dump_attachment(attachment) + data.pop("Metadata", None) + return AttachmentTicket(data) + + +def with_tickets( + model: BaseModel, dumped: dict[str, Any] +) -> tuple[dict[str, Any], set[str]]: + """``model.model_dump()`` with each attachment as an :class:`AttachmentTicket`. + + Returns the fields and the names of the top-level fields that hold an + attachment. + """ + top: set[str] = set() + + def swap(node: Any, data: Any) -> Any: + if isinstance(node, Attachment): + return ticket(node) + if isinstance(node, BaseModel) and isinstance(data, dict): + for name in type(node).model_fields: + if name in data: + data[name] = swap(getattr(node, name, None), data[name]) + return data + if isinstance(node, (list, tuple)) and isinstance(data, list): + items = [swap(n, d) for n, d in zip(node, data, strict=False)] + if any(isinstance(i, AttachmentTicket) for i in items): + return _TicketList(items) + return items + return data + + for name in type(model).model_fields: + if name not in dumped: + continue + value = getattr(model, name, None) + if model_attachments(value): + top.add(name) + dumped[name] = swap(value, dumped[name]) + return dumped, top + + +def attachments_block(attachments: Iterable[Attachment]) -> str: + """The prompt block listing input attachments a template does not show.""" + items = [dict(ticket(a)) for a in attachments] + return "Input attachments (JobAttachment objects):\n" + json.dumps( + items, separators=(",", ":") + ) + + +# --- tracing ---------------------------------------------------------------------- + + +def span_attachments(attachments: Iterable[Attachment], direction: str) -> str | None: + """The ``attachments`` span attribute (``SpanAttachment`` JSON), or None. + + ``direction`` is ``"in"`` or ``"out"``. Never raises. + """ + try: + from uipath.tracing import ( + AttachmentDirection, + AttachmentProvider, + SpanAttachment, + ) + + kind = AttachmentDirection.IN if direction == "in" else AttachmentDirection.OUT + items = [ + SpanAttachment( + id=str(a.id), + file_name=a.full_name, + mime_type=a.mime_type, + provider=AttachmentProvider.ORCHESTRATOR, + direction=kind, + ).model_dump(by_alias=True, mode="json") + for a in attachments + ] + return json.dumps(items) if items else None + except Exception: + logger.debug("could not build the attachments span attribute", exc_info=True) + return None + + +def merge_span_attachments(existing: Any, extra: str | None) -> str | None: + """Two ``attachments`` attribute values as one (``existing`` first).""" + if not extra: + return existing if isinstance(existing, str) else None + try: + old = json.loads(existing) if isinstance(existing, str) else [] + return json.dumps([*old, *json.loads(extra)]) + except ValueError: + return extra + + +__all__ = [ + "AttachmentStore", + "AttachmentTicket", + "UiPathAttachment", + "attachment_paths", + "attachments_block", + "dump_attachment", + "is_attachment_schema", + "merge_span_attachments", + "model_attachments", + "model_has_attachments", + "not_found_message", + "path_text", + "span_attachments", + "to_attachment", + "values_at", + "with_tickets", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/batch_transform_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/batch_transform_tools.py new file mode 100644 index 00000000..1d1a1adf --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/batch_transform_tools.py @@ -0,0 +1,838 @@ +"""Batch Transform on a job attachment: the low-code internal tool that adds columns to a CSV. + +The model passes a CSV attachment (a JobAttachment, resolved by the tool host) +and a ``query``. The tool is *durable*: one call to +``start_batch_transform_from_attachment_async`` (the service indexes the file +and starts the task) and the run suspends on it. When the task ends the run +resumes, the result CSV is downloaded, uploaded as a job attachment +(``output.csv``) and the model gets langchain's result +``{"result": {"ID", "FullName", "MimeType"}}``; the attachment is then known +to the run, so the model can pass it on or return it. + +A failed task faults the run, as in langchain (the platform's trigger reader +raises on it). + +Typical use:: + + from uipath_codex_sdk import uipath_batch_transform_attachments_tool + + enrich = uipath_batch_transform_attachments_tool( + "Enrich Reviews", + output_columns=[("sentiment", "positive, neutral or negative")], + ) +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import logging +import os +import re +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Mapping, Sequence + +from uipath.platform.common import WaitBatchTransform + +from . import tools as _tools +from ._fixed_args import ( + fixed_args_schema, + missing_args_message, + missing_from_input, + with_fixed_args, +) +from .integration_tools import _is_httpx_timeout, _lowcode_tool_name +from .tools import ( + DurableToolContext, + UiPathDurableToolError, + UiPathToolError, + _validate_against_schema, + sanitize_tool_name, +) + +if TYPE_CHECKING: + from .runtime.mocking import MockSpan, MockSpec + +logger = logging.getLogger(__name__) + +__all__ = [ + "BATCH_TRANSFORM_OUTPUT_SCHEMA", + "UiPathBatchTransformAttachmentsTool", + "WaitBatchTransformFile", + "batch_transform_tool_from_resource", + "uipath_batch_transform_attachments_tool", +] + +#: The Agent Builder description of the Batch Transform internal tool. +DEFAULT_BATCH_TRANSFORM_DESCRIPTION = "Process and transform CSV files." +#: langchain's description of the ``query`` argument. +BATCH_TRANSFORM_QUERY_DESCRIPTION = ( + "Describe the task: what to research, what to synthesize." +) +#: ``properties.toolType`` of the low-code resource. +BATCH_TRANSFORM_TOOL_TYPE = "batch-transform" +#: The service's prompt limit. +BATCH_TRANSFORM_PROMPT_MAX = 250_000 +#: Name of the uploaded result (langchain's). +DEFAULT_RESULT_NAME = "output.csv" +#: Where the trigger reader downloads results to (relative to the cwd). +RESULT_DIR = os.path.join(".uipath", "batch_transform") +#: How often an upload of the result is tried. +UPLOAD_ATTEMPTS = 3 + +_COMPLETED = re.compile( + r"^Batch transform completed\. Modified file available at (.+)$" +) + +#: langchain's ``BATCH_TRANSFORM_OUTPUT_SCHEMA``: the result attachment. +BATCH_TRANSFORM_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/job-attachment", + "description": "The transformed result file as an attachment", + } + }, + "required": ["result"], + "definitions": { + "job-attachment": { + "type": "object", + "properties": { + "ID": {"type": "string", "description": "Orchestrator attachment key"}, + "FullName": {"type": "string", "description": "File name"}, + "MimeType": { + "type": "string", + "description": "The MIME type of the content", + }, + }, + "required": ["ID", "FullName", "MimeType"], + "x-uipath-resource-kind": "JobAttachment", + } + }, +} + + +class WaitBatchTransformFile(WaitBatchTransform): + """``WaitBatchTransform`` read by codex's trigger reader, not the platform's. + + The platform's BatchRag reader downloads the result through the storage + URL the service returns, unauthenticated and unchecked; a serverless + robot cannot always reach that host. Its trigger payload carries + ``reader: "codex"``, so :func:`read_batch_transform_trigger` only checks + the task's status and ``finish`` downloads the result itself (through + the authenticated endpoint first). ``destination_path`` keeps the payload readable + by the platform's reader too. + """ + + destination_path: str + reader: str = "codex" + + +def is_codex_batch_transform_trigger(trigger: Any) -> bool: + """True for a BatchRag trigger of :class:`WaitBatchTransformFile`.""" + from uipath.core.triggers import UiPathResumeTriggerType + + payload = getattr(trigger, "payload", None) + if isinstance(payload, str): + try: + payload = json.loads(payload) + except ValueError: + return False + return ( + getattr(trigger, "trigger_type", None) == UiPathResumeTriggerType.BATCH_RAG + and isinstance(payload, dict) + and payload.get("reader") == "codex" + ) + + +async def read_batch_transform_trigger(trigger: Any) -> dict[str, Any]: + """The resume value of a finished task: ``{"batch_transform_id", "status"}``. + + Raises: + UiPathPendingTriggerError: The task has not finished. + UiPathFaultedTriggerError: The task failed (the run faults, as in + langchain). + """ + from uipath.core.errors import ( + ErrorCategory, + UiPathFaultedTriggerError, + UiPathPendingTriggerError, + ) + + task_id = str(trigger.item_key) + task = ( + await _tools._uipath_client().context_grounding.retrieve_batch_transform_async( + id=task_id + ) + ) + status = str( + getattr(task.last_batch_rag_status, "value", task.last_batch_rag_status) + ) + if status.lower() == "failed": + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, f"Batch transform '{task_id}' failed." + ) + if status.lower() != "successful": + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Batch transform '{task_id}' is not complete. Current status: {status}", + ) + return {"batch_transform_id": task_id, "status": status} + + +def batch_transform_attachments_input_schema() -> dict[str, Any]: + """The Agent Builder arguments: one CSV ``attachment`` (``query`` is added).""" + from .attachment_tools import analyze_files_input_schema + + return { + "type": "object", + "properties": { + "attachment": { + "$ref": "#/definitions/job-attachment", + "title": "Source", + "description": "CSV file to transform", + } + }, + "required": ["attachment"], + "definitions": copy.deepcopy(analyze_files_input_schema()["definitions"]), + } + + +def _columns(label: str, columns: Sequence[Any]) -> list[Any]: + """``BatchTransformOutputColumn``s; at least one, each named and described.""" + from pydantic import ValidationError + from uipath.platform.context_grounding import BatchTransformOutputColumn + + out = [] + for column in columns: + if isinstance(column, BatchTransformOutputColumn): + out.append(column) + continue + if isinstance(column, Mapping): + name, description = column.get("name"), column.get("description") + elif isinstance(column, (tuple, list)) and len(column) == 2: + name, description = column + else: + name, description = ( + getattr(column, "name", None), + getattr(column, "description", None), + ) + try: + out.append( + BatchTransformOutputColumn( + name=name or "", description=description or "" + ) + ) + except ValidationError as e: + raise ValueError( + f"{label}: output column {name!r} needs a name (letters, digits, " + "spaces, '.,!?-'; at most 500 characters) and a description." + ) from e + if not out: + raise ValueError(f"{label} needs at least one output column.") + return out + + +async def _folder_key(client: Any, folder_path: str | None) -> str | None: + from .context_tools import _folder_key as folder_key + + return await folder_key(client, folder_path) + + +async def _job_folder_key(client: Any) -> str | None: + """The job's folder: ``UIPATH_FOLDER_KEY``, else ``UIPATH_FOLDER_PATH`` looked up.""" + return os.environ.get("UIPATH_FOLDER_KEY") or await _folder_key( + client, os.environ.get("UIPATH_FOLDER_PATH") + ) + + +@dataclass +class UiPathBatchTransformAttachmentsTool: + """A durable tool that runs Batch Transform on a CSV job attachment. + + Prefer :func:`uipath_batch_transform_attachments_tool` or + :func:`batch_transform_tool_from_resource` over constructing this + directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + output_columns: The columns the task adds (``BatchTransformOutputColumn``). + input_json_schema: Arguments without ``query``: an ``attachment``. + query: Fixed task prompt; the model then passes no ``query``. + query_description: Description of the ``query`` argument. + web_search_grounding: Let the task search the web. + fixed_args: Arguments pre-filled from the agent input or static + values (agent.json ``argumentProperties``): static ones are + hidden from the model, agent-input ones stay visible but + optional (the model's value is used when the input has none). + folder_path: Folder the task runs in; None means the job's folder. + result_name: Name of the uploaded result attachment. + display_name: Name of the ``contextGroundingTool`` span. + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span (low-code's). + output_json_schema: The result's schema: its attachment becomes known. + mock_name: Name the tool is simulated by in evals; None means the + display name. + """ + + name: str + description: str + output_columns: list[Any] + input_json_schema: dict[str, Any] = field( + default_factory=batch_transform_attachments_input_schema + ) + query: str | None = None + query_description: str | None = None + web_search_grounding: bool = False + fixed_args: dict[str, Any] = field(default_factory=dict) + folder_path: str | None = None + result_name: str = DEFAULT_RESULT_NAME + display_name: str | None = None + kind: str = "context" + tool_type: str = "ContextGrounding" + output_json_schema: dict[str, Any] = field( + default_factory=lambda: copy.deepcopy(BATCH_TRANSFORM_OUTPUT_SCHEMA) + ) + mock_name: str | None = None + + def __post_init__(self) -> None: + from uipath.platform.context_grounding import ContextGroundingService + + label = f"Batch Transform tool '{self.name}'" + if not hasattr( + ContextGroundingService, "start_batch_transform_from_attachment_async" + ): + raise ValueError( + f"{label}: the installed uipath-platform has no Batch Transform " + "from attachments; upgrade it (>= 0.2.34)." + ) + if self.query is not None and not self.query.strip(): + raise ValueError(f"{label}: the static query is empty.") + self.output_columns = _columns(label, self.output_columns) + props = self.input_json_schema.get("properties") or {} + if "attachment" not in props and "attachment" not in self.fixed_args: + raise ValueError(f"{label}: the input schema has no 'attachment' field.") + + # --- UiPathDurableTool ------------------------------------------------ + + @property + def input_schema(self) -> dict[str, Any]: + """The arguments as the model sees them: ``query`` added, pre-filled ones hidden.""" + schema = copy.deepcopy(self.input_json_schema) + props = schema.setdefault("properties", {}) + required = [r for r in schema.get("required") or [] if r != "query"] + if self.query is None: + props["query"] = { + "type": "string", + "description": self.query_description + or BATCH_TRANSFORM_QUERY_DESCRIPTION, + } + required.append("query") + else: + props.pop("query", None) + schema["required"] = required + return fixed_args_schema(schema, self.fixed_args) + + def merged_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled values (they win). + + An agent-input field the input has no value for keeps the model's + value, as in langchain. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled. + """ + if not self.fixed_args: + return copy.deepcopy(args) + merged = with_fixed_args(args, self.fixed_args, agent_input) + missing = missing_from_input(self.input_json_schema, self.fixed_args, merged) + if missing: + raise UiPathDurableToolError(missing_args_message(self.name, missing)) + return merged + + @property + def prefilled_schema(self) -> dict[str, Any]: + """The arguments, pre-filled fields included (the host resolves their attachments).""" + return self.input_json_schema + + def prefilled_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled values, unvalidated. + + The host checks the job attachments of these (LangChain's order). + """ + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, agent_input) + + def span_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The ``Tool call`` span's input: the arguments with the pre-filled values.""" + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, getattr(ctx, "agent_input", None)) + + def mock_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The arguments the mocker sees: :meth:`merged_args` (langchain's ``StaticArgsHandler``).""" + return self.merged_args(args, getattr(ctx, "agent_input", None)) + + @property + def span_attributes(self) -> dict[str, Any]: + return { + "context_retrieval_mode": "BatchTransform", + "tool.display_name": self.display_name or self.name, + } + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + _validate_against_schema(self.input_schema, args) + query = args.get("query") + if isinstance(query, str) and len(query) > BATCH_TRANSFORM_PROMPT_MAX: + raise ValueError( + f"query is longer than {BATCH_TRANSFORM_PROMPT_MAX} characters" + ) + return json.loads(json.dumps(args, default=str)) + + def started_output(self, value: Any) -> dict[str, Any]: + task = getattr(value, "batch_transform", None) + return { + "status": "started", + "batch_transform_id": getattr(task, "id", None), + "batch_transform_status": getattr(task, "last_batch_rag_status", None), + } + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Start Batch Transform on the attachment; return ``WaitBatchTransformFile``. + + Raises: + UiPathDurableToolError: No query or attachment, an unknown + attachment, no folder, or the service refused the task. + """ + from opentelemetry import trace + from uipath.platform.errors import EnrichedException + + from .attachments import ( + dump_attachment, + not_found_message, + span_attachments, + to_attachment, + ) + from .runtime.telemetry import ( + end_context_grounding_span, + hold_span, + start_context_grounding_span, + ) + + args = self.merged_args(args, ctx.agent_input) + query = self.query + if query is None: + raw_query = args.get("query") + query = raw_query.strip() if isinstance(raw_query, str) else None + if not query: + raise UiPathDurableToolError("Query is required for Batch Transform tool") + if len(query) > BATCH_TRANSFORM_PROMPT_MAX: + raise UiPathDurableToolError( + f"The query of tool '{self.name}' is longer than " + f"{BATCH_TRANSFORM_PROMPT_MAX} characters." + ) + if "attachment" not in args: + raise UiPathDurableToolError("Argument 'attachment' is not available") + raw = args["attachment"] + if not raw: + raise UiPathDurableToolError( + "Attachment is required for Batch Transform tool" + ) + attachment = to_attachment(raw) + if attachment is None: + raw_id = raw.get("ID", raw.get("id")) if isinstance(raw, dict) else None + if not raw_id: + raise UiPathDurableToolError("Attachment ID is required") + attachment = ctx.attachments.get(str(raw_id)) if ctx.attachments else None + if attachment is None: + raise UiPathDurableToolError(not_found_message(str(raw_id))) + args["attachment"] = dump_attachment(attachment) + + span = start_context_grounding_span( + self.display_name or self.name, + retrieval_mode="BatchTransform", + query=query, + output_columns=[c.model_dump() for c in self.output_columns], + web_search_grounding=self.web_search_grounding, + file_extension=attachment.mime_type, + attachments=span_attachments([attachment], "in"), + ) + try: + with trace.use_span(span, end_on_exit=False): + client = _tools._uipath_client() + if self.folder_path: + folder_key = await _folder_key(client, self.folder_path) + if not folder_key: + raise UiPathDurableToolError( + f"Folder '{self.folder_path}' was not found." + ) + else: + folder_key = await _job_folder_key(client) + if not folder_key: + raise UiPathDurableToolError( + "Batch Transform on an attachment needs a folder: the run " + "has none (set UIPATH_FOLDER_PATH for a local run, or the " + "tool's folder_path)." + ) + started = await client.context_grounding.start_batch_transform_from_attachment_async( + name=f"task-{uuid.uuid4()}", + prompt=query, + output_columns=self.output_columns, + attachment=str(attachment.id), + enable_web_search_grounding=self.web_search_grounding, + folder_key=folder_key, + folder_path=None, + ) + except BaseException as e: + end_context_grounding_span(span, error=e) + if isinstance(e, UiPathDurableToolError): + raise + if isinstance(e, EnrichedException): + status = getattr(e, "status_code", None) + info = getattr(e, "error_info", None) + message = getattr(info, "message", None) or str(e).strip() + raise UiPathDurableToolError( + f"Could not start Batch Transform on attachment " + f"'{attachment.full_name}' (HTTP {status}): {message}" + ) from e + if _is_httpx_timeout(e): + raise UiPathDurableToolError( + f"Starting Batch Transform for tool '{self.name}' timed out; it " + "may have started. Retry only if needed." + ) from e + raise + # Saved with the held span: set before hold_span. + span.set_attribute("batch_transform_id", started.id) + hold_span(span, result_attachment_key="result") + logger.info( + "Batch Transform tool %s started task %s on attachment %s (call %s)", + self.name, + started.id, + attachment.id, + ctx.call_id, + ) + return WaitBatchTransformFile( + batch_transform=started, + index_folder_key=folder_key, + # Deterministic: the reader may download more than once. + destination_path=os.path.join(RESULT_DIR, f"{started.id}.csv"), + ) + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's: the whole ``{"result": }``. + + Unlike langchain's attachment tool, the result is not wrapped in a + second ``result``. The simulated attachment does not exist. + """ + from .runtime.mocking import MockSpec + + return MockSpec( + name=self.mock_name or self.display_name or self.name, + description=self.description, + input_schema=self.input_schema, + output_schema=copy.deepcopy(BATCH_TRANSFORM_OUTPUT_SCHEMA), + example_calls=[], + ) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan: + """The ``contextGroundingTool`` child, without a task. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled (before the child opens, as + a real start fails before its span). + """ + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_context_grounding_span + + merged = self.merged_args(args, getattr(ctx, "agent_input", None)) + query = self.query + if query is None: + raw = merged.get("query") + query = raw.strip() if isinstance(raw, str) else "" + span = start_context_grounding_span( + self.display_name or self.name, + retrieval_mode="BatchTransform", + query=query, + output_columns=[c.model_dump() for c in self.output_columns], + web_search_grounding=self.web_search_grounding, + ) + return MockSpan(span) + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """The simulated result as is; the host makes its attachment known.""" + return json.dumps(value, default=str) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """Upload the downloaded result as a job attachment; langchain's result. + + The resume value names the finished task (codex's reader), which is + downloaded here, or the file the platform's reader downloaded. The + upload goes to the job's folder and is linked to the job (when there + is one); a link failure keeps the uploaded attachment. + """ + client = _tools._uipath_client() + if isinstance(resume_value, dict) and resume_value.get("batch_transform_id"): + task_id = str(resume_value["batch_transform_id"]) + path = Path(RESULT_DIR, f"{_safe_id(task_id)}.csv").resolve() + await _download_result(client, task_id, path) + else: + path = _result_path(resume_value) + folder_key = await _job_folder_key(client) + attachment_id = await _upload(client, self.result_name, path, folder_key) + job_key = os.environ.get("UIPATH_JOB_KEY") + if job_key: + try: + await client.jobs.link_attachment_async( + attachment_key=attachment_id, + job_key=uuid.UUID(job_key), + folder_key=folder_key, + ) + except Exception: + logger.warning( + "Batch Transform result %s was uploaded but could not be linked " + "to job %s", + attachment_id, + job_key, + exc_info=True, + ) + try: + path.unlink() + except OSError: + pass + return json.dumps( + { + "result": { + "ID": str(attachment_id), + "FullName": self.result_name, + "MimeType": "text/csv", + } + } + ) + + +def _safe_id(task_id: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9-]+", task_id): + raise UiPathToolError(f"Unexpected Batch Transform id: {task_id[:80]}") + return task_id + + +async def _download_result(client: Any, task_id: str, path: Path) -> None: + """Download a finished task's result to ``path``. + + The service's authenticated download endpoint first: it goes through the + UiPath host, which every robot reaches (a serverless robot cannot resolve + the storage host of the read URL). The read URL (unauthenticated, as the + platform downloads) is the fallback. Every response is checked. + """ + import httpx + from uipath._utils._ssl_context import get_httpx_client_kwargs + from uipath.platform.context_grounding.context_grounding import ( + BatchTransformReadUriResponse, + ) + + cg = client.context_grounding + try: + spec = cg._batch_transform_download_blob_spec(id=task_id) + blob = await cg.request_async(spec.method, spec.endpoint, headers=spec.headers) + content = blob.content + except Exception as e: + logger.info( + "Batch Transform result %s: the authenticated download failed (%s); " + "trying its storage URL", + task_id, + e, + ) + spec = cg._batch_transform_get_read_uri_spec(id=task_id) + response = await cg.request_async( + spec.method, spec.endpoint, headers=spec.headers + ) + read_uri = BatchTransformReadUriResponse.model_validate(response.json()) + try: + async with httpx.AsyncClient(**get_httpx_client_kwargs()) as http: + stored = await http.get(read_uri.uri) + stored.raise_for_status() + content = stored.content + except httpx.HTTPError: + raise e from None + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +def _result_path(resume_value: Any) -> Path: + """The downloaded result named by the trigger reader's resume value.""" + match = _COMPLETED.match(resume_value) if isinstance(resume_value, str) else None + if match is None: + raise UiPathToolError( + f"Unexpected Batch Transform resume value: {str(resume_value)[:200]}" + ) + path = Path(match.group(1)).resolve() + allowed = Path(RESULT_DIR).resolve() + if path.parent != allowed or not path.is_file(): + raise UiPathToolError(f"The Batch Transform result {path} is not available.") + return path + + +async def _upload(client: Any, name: str, path: Path, folder_key: str | None) -> Any: + """``attachments.upload_async``, tried :data:`UPLOAD_ATTEMPTS` times.""" + for attempt in range(1, UPLOAD_ATTEMPTS + 1): + try: + return await client.attachments.upload_async( + name=name, source_path=str(path), folder_key=folder_key + ) + except Exception: + if attempt == UPLOAD_ATTEMPTS: + raise + logger.warning( + "Uploading the Batch Transform result failed (attempt %s); retrying", + attempt, + exc_info=True, + ) + await asyncio.sleep(attempt) + raise AssertionError("unreachable") # pragma: no cover + + +# -------------------------------------------------------------------------- +# factories +# -------------------------------------------------------------------------- +def uipath_batch_transform_attachments_tool( + name: str = "Batch Transform", + *, + output_columns: Sequence[Any], + description: str | None = None, + query: str | None = None, + query_description: str | None = None, + web_search_grounding: bool = False, + attachment_from_input: str | None = None, + folder_path: str | None = None, + result_name: str = DEFAULT_RESULT_NAME, + tool_name: str | None = None, +) -> UiPathBatchTransformAttachmentsTool: + """A durable tool that runs Batch Transform on a CSV job attachment. + + The model passes an ``attachment`` and a ``query``; the run suspends + until the task ends, and the model then gets the result file as a + JobAttachment, ``{"result": {"ID", "FullName", "MimeType"}}``. + + Args: + name: Display name; the model-facing name is it sanitized the way + low-code agents do, unless ``tool_name`` is given. + output_columns: The columns to add: ``(name, description)`` pairs, + ``{"name", "description"}`` dicts or ``BatchTransformOutputColumn``. + description: Shown to the model; defaults to Agent Builder's. + query: A fixed task prompt: the model passes no ``query``. + query_description: Description of the ``query`` argument. + web_search_grounding: Let the task search the web. + attachment_from_input: Path of the attachment in the agent input. Its + value wins; ``attachment`` stays visible to the model but + optional, and the model's is used when the input has none. + folder_path: Folder the task runs in; defaults to the job's folder. + result_name: Name of the uploaded result (default ``output.csv``). + tool_name: Model-facing name override. + + Raises: + ValueError: No or invalid output columns, an empty query, or an SDK + without Batch Transform from attachments. + """ + from .context_tools import _input_path + from .escalation_tools import FixedArg + + fixed: dict[str, Any] = {} + if attachment_from_input: + fixed["attachment"] = FixedArg(path=_input_path(attachment_from_input)) + return UiPathBatchTransformAttachmentsTool( + name=sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name), + description=description or DEFAULT_BATCH_TRANSFORM_DESCRIPTION, + output_columns=list(output_columns), + query=query, + query_description=query_description, + web_search_grounding=web_search_grounding, + fixed_args=fixed, + folder_path=folder_path, + result_name=result_name, + display_name=_lowcode_tool_name(name), + mock_name=name, + ) + + +def batch_transform_tool_from_resource( + resource: Any, + *, + tool_name: str | None = None, +) -> UiPathBatchTransformAttachmentsTool: + """The Batch Transform tool from a low-code internal ``tool`` resource. + + Args: + resource: An ``AgentInternalToolResourceConfig``, or the resource dict + as found in a low-code ``agent.json`` (``"$resourceType": "tool"``, + ``"type": "Internal"``, ``properties.toolType`` + ``"batch-transform"``). Its query (static, or written by the + model), output columns, web search setting and static, argument + and text-builder ``argumentProperties`` are used. ``fileExtension`` + and the resource's ``outputSchema`` are ignored, as in langchain; + ``folderPathPrefix`` is ignored too (the attachment API has none). + tool_name: Model-facing name; defaults to the resource name sanitized + the way low-code agents do. + + Raises: + ValueError: The resource is disabled, is another internal tool, has a + static query without a value, no or invalid output columns, or + does not validate. + """ + from .attachment_tools import fixed_args_from_properties, internal_tool_resource + from .context_tools import _value + + resource, label = internal_tool_resource(resource, BATCH_TRANSFORM_TOOL_TYPE) + settings = resource.properties.settings + setting = settings.query + variant = str(_value(getattr(setting, "variant", "")) or "").lower() + query: str | None = None + if variant == "static": + query = str(getattr(setting, "value", None) or "").strip() or None + if query is None: + raise ValueError( + f"{label}: the static query has no value. Set one, or make the " + "query dynamic so the model writes it." + ) + prefix = getattr(settings.folder_path_prefix, "value", None) + if prefix: + logger.warning( + "%s: folderPathPrefix %r is ignored (Batch Transform on an attachment " + "has no folder prefix).", + label, + prefix, + ) + web = getattr(settings.web_search_grounding, "value", None) + display = _lowcode_tool_name(resource.name) + return UiPathBatchTransformAttachmentsTool( + name=sanitize_tool_name(tool_name) if tool_name else display, + description=resource.description or DEFAULT_BATCH_TRANSFORM_DESCRIPTION, + output_columns=[ + {"name": c.name, "description": c.description} + for c in settings.output_columns + ], + input_json_schema=copy.deepcopy( + resource.input_schema or batch_transform_attachments_input_schema() + ), + query=query, + query_description=getattr(setting, "description", None), + web_search_grounding=str(_value(web) or "") == "Enabled", + fixed_args=fixed_args_from_properties(label, resource.argument_properties), + display_name=display, + mock_name=resource.name, + ) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/context_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/context_tools.py new file mode 100644 index 00000000..75733e55 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/context_tools.py @@ -0,0 +1,1758 @@ +"""Context Grounding indexes as immediate (semantic search) tools. + +A context tool searches one Context Grounding index and returns the best +matching chunks to the model. The runtime serves it on the ``uipath_tools`` +MCP server and answers each call as soon as +``UiPath().context_grounding.unified_search_async(...)`` returns, the call +``uipath_langchain``'s context tool makes (``handle_semantic_search``). The run +never suspends on it. + +Two ways to declare one: + +* :func:`uipath_context_tool`: coded; +* :func:`context_tool_from_resource`: a ``context`` resource copied from a + low-code ``agent.json`` (or an ``AgentContextResourceConfig``). + +The query comes from the model (the default), is fixed (``query=``), or is +read from the agent input (``query_from_input=``, the low-code ``{{arg}}`` +variant). The result is langchain's: ``{"documents": [{"metadata": {...}, +"page_content": "..."}]}``. + +Typical use:: + + from uipath_codex_sdk import UiPathCodexAgent, uipath_context_tool + + agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Answer from the company policies: {question}", + tools=[ + uipath_context_tool( + "Company Policies", + index_name="company-policies", + number_of_results=5, + ) + ], + ) + +The index name and folder are passed to the SDK through a function with the +SDK's ``index`` resource override, so an ``index.[.]`` overwrite +(``bindings.json``) rebinds them. +""" + +from __future__ import annotations + +import copy +import json +import logging +import os +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, Mapping + +from pydantic import ValidationError + +from . import tools as _tools +from ._fixed_args import ( + fixed_args_schema, + missing_args_message, + missing_from_input, + with_fixed_args, +) +from .integration_tools import ( + DEFAULT_TIMEOUT_SEC, + _check_timeout, + _get_path, + _is_httpx_timeout, + _lowcode_tool_name, +) +from .tools import ( + DurableToolContext, + ToolContext, + UiPathDurableToolError, + UiPathDurableToolPending, + UiPathToolError, + _validate_against_schema, + sanitize_tool_name, +) + +if TYPE_CHECKING: + from uipath.agent.models.agent import AgentContextResourceConfig + + from .runtime.mocking import MockSpan, MockSpec + +logger = logging.getLogger(__name__) + +__all__ = [ + "UiPathContextTool", + "UiPathDeepRagAttachmentsTool", + "UiPathDeepRagTool", + "context_tool_from_resource", + "deep_rag_tool_from_resource", + "uipath_context_tool", + "uipath_deep_rag_attachments_tool", + "uipath_deep_rag_tool", +] + +#: langchain's description of the ``query`` argument. +QUERY_DESCRIPTION = "The query to search for in the knowledge base" +#: Retrieval modes answered by a semantic search. langchain sends Structured +#: as a semantic search too (``SearchMode`` has no other value). +SEARCH_MODES = ("Semantic", "Structured") +#: Cap of the JSON result, in characters (the span attribute cap): whole +#: documents are dropped from the end, so the JSON stays valid. +RESULT_CAP = 64_000 + + +# -------------------------------------------------------------------------- +# the tool +# -------------------------------------------------------------------------- +@dataclass +class UiPathContextTool: + """An immediate tool that runs a semantic search on one index. + + Prefer :func:`uipath_context_tool` or :func:`context_tool_from_resource` + over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + index_name: The Context Grounding index searched. It is passed to the + SDK as is, so a resource overwrite can rebind it. + folder_path: Folder of the index; None means the job's folder + (``UIPATH_FOLDER_PATH``), as low-code agents do. + number_of_results: Maximum number of chunks returned. + threshold: Minimum similarity score of a returned chunk. + query: Fixed query; the model then passes no arguments. + query_from_input: Path of the query in the agent input (``{{x}}`` + variant). The model still sees ``query``, marked optional, and its + value is used only when the agent input has none. + folder_path_prefix: Only search documents under this folder of the + index's data source. + folder_path_prefix_from_input: Path of the prefix in the agent input. + file_extension: Only search documents with this extension (``pdf``). + include_system_indexes: Fall back to tenant system indexes when the + index is not found; None means only in Studio (debug) runs, as + low-code agents do. + retrieval_mode: The configured mode (``Semantic`` or ``Structured``), + recorded on the span. + display_name: Name of the ``contextGroundingTool`` span; defaults to + ``name``. + timeout_sec: Host-enforced timeout of one call, in seconds. None + means the default (300). + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span. + mock_name: Name the search is simulated by in evals; None means the + display name (the resource name). + """ + + #: The search is mocked inside ``call`` (under its span), not by the host. + mocks_in_call: ClassVar[bool] = True + + name: str + description: str + index_name: str + folder_path: str | None = None + number_of_results: int = 10 + threshold: float = 0.0 + query: str | None = None + query_from_input: str | None = None + folder_path_prefix: str | None = None + folder_path_prefix_from_input: str | None = None + file_extension: str | None = None + include_system_indexes: bool | None = None + retrieval_mode: str = "Semantic" + display_name: str | None = None + timeout_sec: float | None = DEFAULT_TIMEOUT_SEC + kind: str = "context" + tool_type: str = "ContextGrounding" + mock_name: str | None = None + + def __post_init__(self) -> None: + if not self.index_name: + raise ValueError(f"Context tool '{self.name}': index_name is required.") + if self.query is not None and self.query_from_input is not None: + raise ValueError( + f"Context tool '{self.name}': set query or query_from_input, not both." + ) + if self.query is not None and not self.query.strip(): + raise ValueError(f"Context tool '{self.name}': the static query is empty.") + if self.number_of_results < 1: + raise ValueError( + f"Context tool '{self.name}': number_of_results must be at least 1." + ) + self.query_from_input = _input_path(self.query_from_input) + self.folder_path_prefix_from_input = _input_path( + self.folder_path_prefix_from_input + ) + self.file_extension = _extension(self.file_extension) + self.timeout_sec = _check_timeout(self.name, self.timeout_sec) + + # --- UiPathTool ------------------------------------------------------- + + @property + def input_schema(self) -> dict[str, Any]: + if self.query is not None: + return {"type": "object", "properties": {}} + prop: dict[str, Any] = {"type": "string", "description": QUERY_DESCRIPTION} + if self.query_from_input is not None: + prop["description"] = ( + f"{QUERY_DESCRIPTION} (Set from the agent input " + f"'{self.query_from_input}' when it has a value; fill it only if " + "it does not.)" + ) + return {"type": "object", "properties": {"query": prop}} + return {"type": "object", "properties": {"query": prop}, "required": ["query"]} + + @property + def span_attributes(self) -> dict[str, Any]: + return { + "index_name": self.index_name, + "context_retrieval_mode": self.retrieval_mode, + "tool.display_name": self.display_name or self.name, + } + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + if self.query is not None: + return {} + _validate_against_schema(self.input_schema, args) + query = args.get("query") + if query is not None and not isinstance(query, str): + raise ValueError("query must be a string") + if self.query_from_input is None and not (query or "").strip(): + raise ValueError("query must not be empty") + return {"query": query} if query is not None else {} + + async def call(self, args: dict[str, Any], ctx: ToolContext) -> str: + """Search the index; return the documents as JSON text. + + Raises: + UiPathToolError: The index is missing or still ingesting, the + service refused the search, or it timed out; the message is + shown to the model. + """ + from opentelemetry import trace + from uipath.platform.errors import ( + ContextGroundingIndexNotFoundError, + EnrichedException, + IngestionInProgressException, + ) + + from .runtime.mocking import mark_simulated + from .runtime.telemetry import ( + end_context_grounding_span, + start_context_grounding_span, + ) + + query = self.resolve_query(args, ctx.agent_input) + if not query: + raise UiPathToolError( + f"Tool '{self.name}' needs a query: pass one in 'query'." + ) + prefix = self.resolve_folder_path_prefix(ctx.agent_input) + span = start_context_grounding_span( + self.display_name or self.name, + retrieval_mode=self.retrieval_mode, + query=query, + threshold=self.threshold, + number_of_results=self.number_of_results, + folder_path_prefix=prefix, + file_extension=self.file_extension, + system_index=self._include_system_indexes(), + ) + try: + # Mocked under this span (the marker lands on it); the real + # search runs under a retriever span of its own (``_invoke``). + # The mocker sees langchain's arguments: the query the tool was + # called with (the agent input's when set from it), none for a + # static one; the prefix only reaches the real search. + mock_args = {} if self.query is not None else {"query": query} + with trace.use_span(span, end_on_exit=False): + result = await self._invoke(query, prefix)(**mock_args) + except BaseException as e: + end_context_grounding_span(span, error=e) + if isinstance(e, ContextGroundingIndexNotFoundError): + logger.error( + "Context tool %s: index '%s' was not found.", + self.name, + self.index_name, + ) + raise UiPathToolError( + f"Context grounding index '{self.index_name}' was not found." + ) from e + if isinstance(e, IngestionInProgressException): + logger.warning( + "Context tool %s: index '%s' is still ingesting.", + self.name, + self.index_name, + ) + raise UiPathToolError( + f"Context grounding index '{self.index_name}' is still being " + "ingested. Try again later." + ) from e + if isinstance(e, EnrichedException): + raise UiPathToolError(self._enriched_message(e)) from e + if _is_httpx_timeout(e): + logger.warning("Context grounding search of %s timed out.", self.name) + raise UiPathToolError( + f"Context grounding search for tool '{self.name}' timed out. " + "You may retry." + ) from e + raise + text = _capped_json(result) + mark_simulated(span) + end_context_grounding_span(span, result=text) + return text + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's search: ``{"documents": [...]}``.""" + from .runtime.mocking import MockSpec + + return MockSpec( + name=self.mock_name or self.display_name or self.name, + description=self.description, + input_schema=self.input_schema, + output_schema=DOCUMENTS_SCHEMA, + example_calls=[], # langchain: examples cannot be provided for context + ) + + # --- helpers ---------------------------------------------------------- + + def resolve_query( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> str | None: + """The static query, else the agent input's, else the model's.""" + if self.query is not None: + return self.query + if self.query_from_input is not None: + found, value = _get_path(agent_input, self.query_from_input) + if found and value not in (None, ""): + return str(value) + logger.debug( + "Context tool %s: {{%s}} is not in the agent input; using the " + "model's query.", + self.name, + self.query_from_input, + ) + query = args.get("query") + return query.strip() or None if isinstance(query, str) else None + + def resolve_folder_path_prefix( + self, agent_input: dict[str, Any] | None + ) -> str | None: + if self.folder_path_prefix: + return self.folder_path_prefix + if self.folder_path_prefix_from_input is not None: + found, value = _get_path(agent_input, self.folder_path_prefix_from_input) + if found and value not in (None, ""): + return str(value) + return None + + def _include_system_indexes(self) -> bool: + if self.include_system_indexes is not None: + return self.include_system_indexes + try: + from uipath.platform.common import UiPathConfig + + return bool(UiPathConfig.is_studio_project) + except Exception: # pragma: no cover - config unavailable + return False + + def _invoke(self, query: str, folder_path_prefix: str | None) -> Any: + """The search for ``query``, ``mockable`` with :attr:`mock_spec`. + + The real search runs under a ``ContextGroundingRetriever`` span; a + simulated one has none (langchain's retriever is inside its mocked + function too). The resolved query and prefix are bound here, so the + mocked function's arguments are only the model-facing ones. + """ + from opentelemetry import trace + + from .runtime.mocking import wrap + from .runtime.telemetry import end_retriever_span, start_retriever_span + + async def search(**_kwargs: Any) -> Any: + retriever = start_retriever_span(query) + try: + with trace.use_span(retriever, end_on_exit=False): + result = await _search_index( + self.index_name, + query, + folder_path=self.folder_path + or os.environ.get("UIPATH_FOLDER_PATH"), + number_of_results=self.number_of_results, + threshold=self.threshold, + folder_path_prefix=folder_path_prefix, + file_extension=self.file_extension, + include_system_indexes=self._include_system_indexes(), + ) + except BaseException as e: + end_retriever_span(retriever, error=e) + raise + end_retriever_span(retriever, result=_capped_json(result)) + return result + + return wrap(self.mock_spec, search) + + def _enriched_message(self, e: Any) -> str: + """langchain's ``_CONTEXT_GROUNDING_ERRORS`` mapping, as model-facing text.""" + info = getattr(e, "error_info", None) + message = (getattr(info, "message", None) if info is not None else None) or str( + e + ).strip() + status = getattr(e, "status_code", None) + if status == 400: + logger.warning( + "Context grounding rejected the search of tool %s (HTTP 400): %s", + self.name, + message, + ) + return ( + f"Context grounding returned an error for index " + f"'{self.index_name}': {message}" + ) + logger.error( + "Context grounding search of tool %s failed (HTTP %s): %s", + self.name, + status, + message, + ) + return ( + f"Failed to search context index '{self.index_name}' " + f"(HTTP {status}): {message}" + ) + + +#: Output schema of a context tool (eval mocks). +DOCUMENTS_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "documents": { + "type": "array", + "description": "List of retrieved documents.", + "items": { + "type": "object", + "properties": { + "metadata": {"type": "object"}, + "page_content": {"type": "string"}, + }, + "required": ["page_content"], + }, + } + }, + "required": ["documents"], +} + + +# -------------------------------------------------------------------------- +# the search +# -------------------------------------------------------------------------- +_folder_keys: dict[str, str | None] = {} + + +def _resource_override_index(func: Any) -> Any: + try: + from uipath.platform.common import resource_override + except Exception: # pragma: no cover - platform missing + return func + return resource_override(resource_type="index")(func) + + +@_resource_override_index +async def _search_index( + name: str, + query: str, + folder_path: str | None = None, + *, + number_of_results: int, + threshold: float, + folder_path_prefix: str | None, + file_extension: str | None, + include_system_indexes: bool, +) -> dict[str, Any]: + """``unified_search_async`` on ``name``, mapped to langchain's documents. + + ``name`` and ``folder_path`` go through the SDK's ``index`` resource + override first (this function carries it). The folder key is then + resolved asynchronously: given only a path, the SDK resolves it with a + blocking call, which would stall the event loop the gateway proxy and the + tool host share. + """ + from uipath.platform.context_grounding import SearchMode, UnifiedSearchScope + + client = _tools._uipath_client() + folder_key = await _folder_key(client, folder_path) + scope = ( + UnifiedSearchScope(folder=folder_path_prefix, extension=file_extension) + if folder_path_prefix or file_extension + else None + ) + result = await client.context_grounding.unified_search_async( + name=name, + query=query, + search_mode=SearchMode.SEMANTIC, + number_of_results=number_of_results, + threshold=threshold, + scope=scope, + folder_key=folder_key, + # Only without a key: the SDK then searches across folders, as it + # does for a path it cannot resolve. + folder_path=None if folder_key else folder_path, + include_system_indexes=include_system_indexes, + ) + return {"documents": documents_from_result(result)} + + +async def _folder_key(client: Any, folder_path: str | None) -> str | None: + """Key of ``folder_path``: the job's own key for the job's folder, else looked up.""" + if not folder_path: + return None + if folder_path == os.environ.get("UIPATH_FOLDER_PATH") and os.environ.get( + "UIPATH_FOLDER_KEY" + ): + return os.environ["UIPATH_FOLDER_KEY"] + if folder_path not in _folder_keys: + _folder_keys[folder_path] = await client.folders.retrieve_key_async( + folder_path=folder_path + ) + return _folder_keys[folder_path] + + +def documents_from_result(result: Any) -> list[dict[str, Any]]: + """langchain's ``ContextGroundingRetriever`` documents of a unified search.""" + semantic = getattr(result, "semantic_results", None) + values = getattr(semantic, "values", None) or [] + metadata = getattr(semantic, "metadata", None) + search_id = getattr(metadata, "operation_id", None) if metadata else None + return [ + { + "metadata": { + "source": item.source, + "search_id": search_id, + "reference": item.reference, + "page_number": item.page_number, + "score": item.score, + }, + "page_content": item.content, + } + for item in values + ] + + +def _capped_json(result: Any) -> str: + """``result`` as JSON of at most :data:`RESULT_CAP` characters. + + Whole documents are dropped from the end (``"truncated": true`` is then + added); a single document longer than the cap has its text cut. + """ + text = json.dumps(result, ensure_ascii=False, default=str) + if len(text) <= RESULT_CAP or not isinstance(result, dict): + return text + docs = list(result.get("documents") or []) + while docs: + out = {**result, "documents": docs, "truncated": True} + text = json.dumps(out, ensure_ascii=False, default=str) + if len(text) <= RESULT_CAP: + return text + if len(docs) == 1: + doc = dict(docs[0]) + content = str(doc.get("page_content") or "") + over = len(text) - RESULT_CAP + doc["page_content"] = content[: max(0, len(content) - over - 1)] + "…" + return json.dumps( + {**out, "documents": [doc]}, ensure_ascii=False, default=str + ) + docs.pop() + return json.dumps({**result, "documents": [], "truncated": True}) + + +# -------------------------------------------------------------------------- +# DeepRAG (durable) +# -------------------------------------------------------------------------- +#: langchain's description of the DeepRAG ``query`` argument. +DEEP_RAG_QUERY_DESCRIPTION = ( + "Describe the task: what to research across documents, what to synthesize, " + "and how to cite sources" +) +#: The DeepRAG service's prompt limit. +DEEP_RAG_PROMPT_MAX = 250_000 +_DEEP_RAG_PENDING = frozenset({"queued", "inprogress"}) + + +@dataclass +class UiPathDeepRagTool: + """A durable tool that runs a DeepRAG task on one index. + + The call starts the task and suspends the run; when the task ends the run + resumes and the model gets langchain's DeepRAG result: + ``{"text": ..., "citations": [...], "deepRagId": ...}``. Prefer + :func:`uipath_deep_rag_tool` or :func:`context_tool_from_resource` over + constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + index_name: The index the task researches (a bindable ``index`` + resource). + citation_mode: ``Inline`` (citations in the text) or ``Skip``. + folder_path: Folder of the index; None means the job's folder. + query: Fixed task prompt; the model then passes no arguments. + query_from_input: Path of the prompt in the agent input; the model's + ``query`` is used only when the input has none. + folder_path_prefix: Only research documents under this folder of the + index's data source. + folder_path_prefix_from_input: Path of that prefix in the agent input. + file_extension: Only research documents with this extension. + display_name: Name of the ``contextGroundingTool`` span. + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span. + mock_name: Name the tool is simulated by in evals; None means the + display name (the resource name). + """ + + name: str + description: str + index_name: str + citation_mode: Any = "Inline" + folder_path: str | None = None + query: str | None = None + query_from_input: str | None = None + folder_path_prefix: str | None = None + folder_path_prefix_from_input: str | None = None + file_extension: str | None = None + display_name: str | None = None + kind: str = "context" + tool_type: str = "ContextGrounding" + retrieval_mode: str = "DeepRAG" + mock_name: str | None = None + + def __post_init__(self) -> None: + from uipath.platform.context_grounding import CitationMode + + if not self.index_name: + raise ValueError(f"DeepRAG tool '{self.name}': index_name is required.") + if self.query is not None and self.query_from_input is not None: + raise ValueError( + f"DeepRAG tool '{self.name}': set query or query_from_input, not both." + ) + if self.query is not None and not self.query.strip(): + raise ValueError(f"DeepRAG tool '{self.name}': the static query is empty.") + try: + # The SDK reads citation_mode.value: a plain string would crash. + self.citation_mode = CitationMode(_value(self.citation_mode)) + except ValueError as e: + raise ValueError( + f"DeepRAG tool '{self.name}': citation_mode must be 'Inline' or " + f"'Skip', got {self.citation_mode!r}." + ) from e + self.query_from_input = _input_path(self.query_from_input) + self.folder_path_prefix_from_input = _input_path( + self.folder_path_prefix_from_input + ) + self.file_extension = _extension(self.file_extension) + + # --- UiPathDurableTool ------------------------------------------------ + + @property + def input_schema(self) -> dict[str, Any]: + if self.query is not None: + return {"type": "object", "properties": {}} + description = DEEP_RAG_QUERY_DESCRIPTION + if self.query_from_input is not None: + description += ( + f" (Set from the agent input '{self.query_from_input}' when it " + "has a value.)" + ) + return { + "type": "object", + "properties": {"query": {"type": "string", "description": description}}, + "required": ["query"], + } + + @property + def span_attributes(self) -> dict[str, Any]: + return { + "index_name": self.index_name, + "context_retrieval_mode": self.retrieval_mode, + "tool.display_name": self.display_name or self.name, + } + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + if self.query is not None: + return {} + _validate_against_schema(self.input_schema, args) + query = args.get("query") + if not isinstance(query, str) or not query.strip(): + raise ValueError("query must be a non-empty string") + if len(query) > DEEP_RAG_PROMPT_MAX: + raise ValueError(f"query is longer than {DEEP_RAG_PROMPT_MAX} characters") + return {"query": query} + + def started_output(self, value: Any) -> dict[str, Any]: + return _deep_rag_started_output(value) + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Start the DeepRAG task; return ``WaitDeepRagRaw`` for it. + + The task is started here rather than by the resume trigger (as + langchain's ``CreateDeepRag`` does), so a start error comes back to + the model as a tool error, and a failed task as a result instead of a + faulted trigger. A ``contextGroundingTool`` span is left open until + the task's result is used. + + Raises: + UiPathDurableToolError: The index is missing or still ingesting, + or the service refused the task. + """ + from opentelemetry import trace + from uipath.platform.common import WaitDeepRagRaw + from uipath.platform.errors import ( + ContextGroundingIndexNotFoundError, + EnrichedException, + IngestionInProgressException, + ) + + from .runtime.telemetry import ( + end_context_grounding_span, + hold_span, + start_context_grounding_span, + ) + + query = _resolve(self.query, self.query_from_input, args, ctx.agent_input) + if not query: + raise UiPathDurableToolError( + f"Tool '{self.name}' needs a query: pass one in 'query'." + ) + if len(query) > DEEP_RAG_PROMPT_MAX: + raise UiPathDurableToolError( + f"The query of tool '{self.name}' is longer than " + f"{DEEP_RAG_PROMPT_MAX} characters." + ) + prefix = self.folder_path_prefix or _input_value( + ctx.agent_input, self.folder_path_prefix_from_input + ) + glob = build_glob_pattern(prefix, self.file_extension) + span = start_context_grounding_span( + self.display_name or self.name, + retrieval_mode=self.retrieval_mode, + query=query, + citation_mode=_value(self.citation_mode), + folder_path_prefix=prefix, + file_extension=self.file_extension, + ) + try: + with trace.use_span(span, end_on_exit=False): + started, folder_key, folder_path = await _start_deep_rag( + self.index_name, + folder_path=self.folder_path + or os.environ.get("UIPATH_FOLDER_PATH"), + prompt=query, + glob_pattern=glob, + citation_mode=self.citation_mode, + ) + except BaseException as e: + end_context_grounding_span(span, error=e) + if isinstance(e, ContextGroundingIndexNotFoundError): + raise UiPathDurableToolError( + f"Context grounding index '{self.index_name}' was not found." + ) from e + if isinstance(e, IngestionInProgressException): + raise UiPathDurableToolError( + f"Context grounding index '{self.index_name}' is still being " + "ingested. Try again later." + ) from e + if isinstance(e, EnrichedException): + status = getattr(e, "status_code", None) + info = getattr(e, "error_info", None) + message = getattr(info, "message", None) or str(e).strip() + raise UiPathDurableToolError( + f"Could not start DeepRAG on index '{self.index_name}' " + f"(HTTP {status}): {message}" + ) from e + if _is_httpx_timeout(e): + raise UiPathDurableToolError( + f"Starting DeepRAG for tool '{self.name}' timed out. You may retry." + ) from e + raise + span.set_attribute("deep_rag_id", started.id) + hold_span(span) + logger.info( + "DeepRAG tool %s started task %s (call %s)", + self.name, + started.id, + ctx.call_id, + ) + return WaitDeepRagRaw( + deep_rag=started, + index_folder_key=folder_key, + index_folder_path=None if folder_key else folder_path, + ) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """Map the finished task (or a raw resume payload) to the result text. + + * successful → ``{"text", "citations", "deepRagId"}`` (langchain's); + * failed → ``{"status": "failed", "error": , "deepRagId"}``; + * queued or in progress → :class:`UiPathDurableToolPending`; + * anything else (a raw ``--resume`` payload) → JSON-dumped as is. + """ + return _deep_rag_result(resume_value) + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's DeepRAG: ``{"text", "citations", "deepRagId"}``.""" + from .runtime.mocking import MockSpec + + return MockSpec( + name=self.mock_name or self.display_name or self.name, + description=self.description, + input_schema=self.input_schema, + output_schema=deep_rag_output_schema(), + example_calls=[], + ) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan: + """The ``contextGroundingTool`` child, without a task.""" + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_context_grounding_span + + agent_input = getattr(ctx, "agent_input", None) + span = start_context_grounding_span( + self.display_name or self.name, + retrieval_mode=self.retrieval_mode, + query=_resolve(self.query, self.query_from_input, args, agent_input) or "", + citation_mode=_value(self.citation_mode), + folder_path_prefix=self.folder_path_prefix + or _input_value(agent_input, self.folder_path_prefix_from_input), + file_extension=self.file_extension, + ) + return MockSpan(span) + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """The simulated result as is (a successful task's shape).""" + return _dumps(value) + + +def deep_rag_output_schema() -> dict[str, Any]: + """langchain's ``DeepRagOutputModel``: ``DeepRagContent`` plus ``deepRagId``.""" + from pydantic import Field, create_model + from uipath.platform.context_grounding import DeepRagContent + + model = create_model( + "DeepRagOutputModel", + __base__=DeepRagContent, + deep_rag_id=(str, Field(alias="deepRagId")), + ) + return model.model_json_schema() + + +def _deep_rag_started_output(value: Any) -> dict[str, Any]: + """The ``Tool call`` output while suspended on a DeepRAG task.""" + deep_rag = getattr(value, "deep_rag", None) + return { + "status": "started", + "deep_rag_id": getattr(deep_rag, "id", None), + "deep_rag_status": getattr(deep_rag, "last_deep_rag_status", None), + } + + +def _deep_rag_result(resume_value: Any) -> str: + """The model-facing result of a finished DeepRAG task (see ``finish``).""" + deep_rag = _deep_rag_from(resume_value) + if deep_rag is None: + return _dumps(resume_value) + status = str(_value(deep_rag.last_deep_rag_status) or "") + if status.lower() in _DEEP_RAG_PENDING: + raise UiPathDurableToolPending(f"DeepRAG {deep_rag.id} is {status}") + if status.lower() == "successful" and deep_rag.content is not None: + return json.dumps( + {**deep_rag.content.model_dump(), "deepRagId": deep_rag.id}, + ensure_ascii=False, + default=str, + ) + result: dict[str, Any] = { + "status": status.lower() or "unknown", + "deepRagId": deep_rag.id, + } + if status.lower() != "successful": + result["error"] = deep_rag.failure_reason or "DeepRAG did not succeed." + return json.dumps(result, ensure_ascii=False) + + +# -------------------------------------------------------------------------- +# DeepRAG on a job attachment (durable) +# -------------------------------------------------------------------------- +#: langchain's description of the internal DeepRAG tool's ``query`` argument. +DEEP_RAG_ATTACHMENTS_QUERY_DESCRIPTION = ( + "Describe the task: what to research across documents, what to synthesize " + "and how to cite sources." +) +#: The Agent Builder description of the DeepRAG internal tool. +DEFAULT_DEEP_RAG_ATTACHMENTS_DESCRIPTION = ( + "Get comprehensive synthesis across up to 1,000 pages with citations." +) +#: ``properties.toolType`` of the low-code resource. +DEEP_RAG_TOOL_TYPE = "deep-rag" + + +def deep_rag_attachments_input_schema() -> dict[str, Any]: + """The Agent Builder DeepRAG arguments: one ``attachment`` (``query`` is added).""" + from .attachment_tools import analyze_files_input_schema + + definitions = analyze_files_input_schema()["definitions"] + return { + "type": "object", + "properties": { + "attachment": { + "$ref": "#/definitions/job-attachment", + "title": "Source", + "description": "File or document to analyze with DeepRAG", + } + }, + "required": ["attachment"], + "definitions": copy.deepcopy(definitions), + } + + +@dataclass +class UiPathDeepRagAttachmentsTool: + """A durable tool that runs a DeepRAG task on a job attachment. + + One call to ``start_deep_rag_from_attachments_async``: the service builds + a temporary index of the file and starts the task, and the run suspends + on it (the same ``WaitDeepRagRaw`` as :class:`UiPathDeepRagTool`). The + model gets ``{"text", "citations", "deepRagId"}``. DeepRAG reads PDF and + TXT files. Prefer :func:`uipath_deep_rag_attachments_tool` or + :func:`deep_rag_tool_from_resource` over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + input_json_schema: Arguments without ``query``: an ``attachment`` + (a JobAttachment, resolved by the tool host). + citation_mode: ``Inline`` (citations in the text) or ``Skip``. + query: Fixed task prompt; the model then passes no ``query``. + query_from_input: Path of the prompt in the agent input; the model's + ``query`` is used only when the input has none. + query_description: Description of the ``query`` argument. + fixed_args: Arguments pre-filled from the agent input or static + values (agent.json ``argumentProperties``): static ones are + hidden from the model, agent-input ones stay visible but + optional (the model's value is used when the input has none). + folder_path: Folder the task runs in; None means the job's folder. + display_name: Name of the internal tool span. + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span. + mock_name: Name the tool is simulated by in evals; None means the + display name. + mock_output_schema: Output schema of a simulated call (the + resource's ``outputSchema``); None means langchain's DeepRAG + result. + """ + + name: str + description: str + input_json_schema: dict[str, Any] = field( + default_factory=deep_rag_attachments_input_schema + ) + citation_mode: Any = "Inline" + query: str | None = None + query_from_input: str | None = None + query_description: str | None = None + fixed_args: dict[str, Any] = field(default_factory=dict) + folder_path: str | None = None + display_name: str | None = None + kind: str = "internal" + tool_type: str = "Internal" + mock_name: str | None = None + mock_output_schema: dict[str, Any] | None = None + + def __post_init__(self) -> None: + from uipath.platform.context_grounding import CitationMode + + label = f"DeepRAG tool '{self.name}'" + if self.query is not None and self.query_from_input is not None: + raise ValueError(f"{label}: set query or query_from_input, not both.") + if self.query is not None and not self.query.strip(): + raise ValueError(f"{label}: the static query is empty.") + try: + # The SDK reads citation_mode.value: a plain string would crash. + self.citation_mode = CitationMode(_value(self.citation_mode)) + except ValueError as e: + raise ValueError( + f"{label}: citation_mode must be 'Inline' or 'Skip', got " + f"{self.citation_mode!r}." + ) from e + self.query_from_input = _input_path(self.query_from_input) + props = self.input_json_schema.get("properties") or {} + if "attachment" not in props and "attachment" not in self.fixed_args: + raise ValueError(f"{label}: the input schema has no 'attachment' field.") + + # --- UiPathDurableTool ------------------------------------------------ + + @property + def input_schema(self) -> dict[str, Any]: + """The arguments as the model sees them: ``query`` added, pre-filled ones hidden.""" + schema = copy.deepcopy(self.input_json_schema) + props = schema.setdefault("properties", {}) + required = [r for r in schema.get("required") or [] if r != "query"] + if self.query is None: + description = ( + self.query_description or DEEP_RAG_ATTACHMENTS_QUERY_DESCRIPTION + ) + if self.query_from_input is not None: + description += ( + f" (Set from the agent input '{self.query_from_input}' when it " + "has a value.)" + ) + props["query"] = {"type": "string", "description": description} + required.append("query") + else: + props.pop("query", None) + schema["required"] = required + return fixed_args_schema(schema, self.fixed_args) + + @property + def span_attributes(self) -> dict[str, Any]: + return {"tool.display_name": self.display_name or self.name} + + def merged_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled values (they win). + + An agent-input field the input has no value for keeps the model's + value, as in langchain. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled. + """ + if not self.fixed_args: + return copy.deepcopy(args) + merged = with_fixed_args(args, self.fixed_args, agent_input) + missing = missing_from_input(self.input_json_schema, self.fixed_args, merged) + if missing: + raise UiPathDurableToolError(missing_args_message(self.name, missing)) + return merged + + @property + def prefilled_schema(self) -> dict[str, Any]: + """The arguments, pre-filled fields included (the host resolves their attachments).""" + return self.input_json_schema + + def prefilled_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled values, unvalidated. + + The host checks the job attachments of these (LangChain's order). + """ + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, agent_input) + + def span_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The ``Tool call`` span's input: the arguments with the pre-filled values.""" + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, getattr(ctx, "agent_input", None)) + + def mock_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The arguments the mocker sees: :meth:`merged_args` (langchain's ``StaticArgsHandler``).""" + return self.merged_args(args, getattr(ctx, "agent_input", None)) + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + _validate_against_schema(self.input_schema, args) + query = args.get("query") + if isinstance(query, str) and len(query) > DEEP_RAG_PROMPT_MAX: + raise ValueError(f"query is longer than {DEEP_RAG_PROMPT_MAX} characters") + return json.loads(json.dumps(args, default=str)) + + def started_output(self, value: Any) -> dict[str, Any]: + return _deep_rag_started_output(value) + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Start DeepRAG on the attachment; return ``WaitDeepRagRaw`` for it. + + Raises: + UiPathDurableToolError: No query or attachment, an unknown + attachment, or the service refused the task (e.g. a file type + DeepRAG cannot read). + """ + from opentelemetry import trace + from uipath.platform.common import WaitDeepRagRaw + from uipath.platform.errors import EnrichedException + + from .attachments import ( + dump_attachment, + not_found_message, + span_attachments, + to_attachment, + ) + from .runtime.telemetry import fail_span, hold_span, start_internal_tool_span + + args = self.merged_args(args, ctx.agent_input) + query = _resolve(self.query, self.query_from_input, args, ctx.agent_input) + if not query: + raise UiPathDurableToolError("Query is required for DeepRAG tool") + if len(query) > DEEP_RAG_PROMPT_MAX: + raise UiPathDurableToolError( + f"The query of tool '{self.name}' is longer than " + f"{DEEP_RAG_PROMPT_MAX} characters." + ) + if "attachment" not in args: + raise UiPathDurableToolError("Argument 'attachment' is not available") + raw = args["attachment"] + if not raw: + raise UiPathDurableToolError("Attachment is required for DeepRAG tool") + attachment = to_attachment(raw) + if attachment is None: + raw_id = raw.get("ID", raw.get("id")) if isinstance(raw, dict) else None + if not raw_id: + raise UiPathDurableToolError("Attachment ID is required") + # A pre-filled argument the host did not resolve. + attachment = ctx.attachments.get(str(raw_id)) if ctx.attachments else None + if attachment is None: + raise UiPathDurableToolError(not_found_message(str(raw_id))) + args["attachment"] = dump_attachment(attachment) + args["query"] = query + + span = start_internal_tool_span( + self.display_name or self.name, + arguments=args, + tool_type=self.tool_type, + citation_mode=_value(self.citation_mode), + attachments=span_attachments([attachment], "in"), + ) + try: + with trace.use_span(span, end_on_exit=False): + started, folder_key = await _start_deep_rag_from_attachments( + prompt=query, + attachment_id=str(attachment.id), + citation_mode=self.citation_mode, + folder_path=self.folder_path, + ) + except BaseException as e: + fail_span(span, e) + span.end() + if isinstance(e, UiPathDurableToolError): + raise + if isinstance(e, EnrichedException): + status = getattr(e, "status_code", None) + info = getattr(e, "error_info", None) + message = getattr(info, "message", None) or str(e).strip() + raise UiPathDurableToolError( + f"Could not start DeepRAG on attachment " + f"'{attachment.full_name}' (HTTP {status}): {message}" + ) from e + if _is_httpx_timeout(e): + raise UiPathDurableToolError( + f"Starting DeepRAG for tool '{self.name}' timed out. You may retry." + ) from e + raise + # Saved with the held span: set before hold_span. + span.set_attribute("deep_rag_id", started.id) + span.set_attribute("output.mime_type", "application/json") + hold_span(span) + logger.info( + "DeepRAG tool %s started task %s on attachment %s (call %s)", + self.name, + started.id, + attachment.id, + ctx.call_id, + ) + return WaitDeepRagRaw(deep_rag=started, index_folder_key=folder_key) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """Map the finished task to the result text, as :class:`UiPathDeepRagTool`.""" + return _deep_rag_result(resume_value) + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated with the resource's output schema (langchain's), else DeepRAG's.""" + from .runtime.mocking import MockSpec + + return MockSpec( + name=self.mock_name or self.display_name or self.name, + description=self.description, + input_schema=self.input_schema, + output_schema=copy.deepcopy(self.mock_output_schema or {}) + or deep_rag_output_schema(), + example_calls=[], + ) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan: + """The internal tool child, without a task. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled (before the child opens, as + a real start fails before its span). + """ + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_internal_tool_span + + span = start_internal_tool_span( + self.display_name or self.name, + arguments=self.merged_args(args, getattr(ctx, "agent_input", None)), + tool_type=self.tool_type, + citation_mode=_value(self.citation_mode), + ) + return MockSpan(span) + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """The simulated result as is.""" + return _dumps(value) + + +async def _start_deep_rag_from_attachments( + *, prompt: str, attachment_id: str, citation_mode: Any, folder_path: str | None +) -> tuple[Any, str | None]: + """``start_deep_rag_from_attachments_async``; ``(task, folder key)``. + + The folder is passed explicitly (the call sends none by itself): the + tool's ``folder_path``, else the job's folder (``UIPATH_FOLDER_KEY``, else + ``UIPATH_FOLDER_PATH`` looked up); the service requires one. Only a key is + sent, so the SDK never looks a path up synchronously. + """ + client = _tools._uipath_client() + if folder_path: + folder_key = await _folder_key(client, folder_path) + if not folder_key: + raise UiPathDurableToolError(f"Folder '{folder_path}' was not found.") + else: + folder_key = os.environ.get("UIPATH_FOLDER_KEY") or await _folder_key( + client, os.environ.get("UIPATH_FOLDER_PATH") + ) + if not folder_key: + # The service refuses the call without a folder (HTTP 400). + raise UiPathDurableToolError( + "DeepRAG on an attachment needs a folder: the run has none (set " + "UIPATH_FOLDER_PATH for a local run, or the tool's folder_path)." + ) + started = await client.context_grounding.start_deep_rag_from_attachments_async( + name=f"task-{uuid.uuid4()}", + prompt=prompt, + attachments=[attachment_id], + citation_mode=citation_mode, + folder_key=folder_key, + folder_path=None, + ) + return started, folder_key + + +def _deep_rag_from(value: Any) -> Any | None: + """``value`` as a ``DeepRagResponse`` when it is (or serializes) one.""" + from uipath.platform.context_grounding import DeepRagResponse + + if isinstance(value, DeepRagResponse): + return value + if isinstance(value, dict) and ( + "lastDeepRagStatus" in value or "last_deep_rag_status" in value + ): + try: + return DeepRagResponse.model_validate(value) + except ValidationError: + return None + return None + + +def _dumps(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, default=str) + + +def _input_value(agent_input: dict[str, Any] | None, path: str | None) -> str | None: + if path is None: + return None + found, value = _get_path(agent_input, path) + return str(value) if found and value not in (None, "") else None + + +def _resolve( + static: str | None, + from_input: str | None, + args: dict[str, Any], + agent_input: dict[str, Any] | None, +) -> str | None: + """The static value, else the agent input's, else the model's ``query``.""" + if static is not None: + return static + value = _input_value(agent_input, from_input) + if value is not None: + return value + query = args.get("query") + return query.strip() or None if isinstance(query, str) else None + + +def _resource_override_deep_rag(func: Any) -> Any: + try: + from uipath.platform.common import resource_override + except Exception: # pragma: no cover - platform missing + return func + return resource_override(resource_type="index", resource_identifier="index_name")( + func + ) + + +@_resource_override_deep_rag +async def _start_deep_rag( + index_name: str, + folder_path: str | None = None, + *, + prompt: str, + glob_pattern: str, + citation_mode: Any, +) -> tuple[Any, str | None, str | None]: + """``start_deep_rag_async`` on ``index_name``; ``(task, folder key, path)``. + + Like :func:`_search_index`: the index binding is applied first, then the + folder key is resolved asynchronously. + """ + client = _tools._uipath_client() + folder_key = await _folder_key(client, folder_path) + started = await client.context_grounding.start_deep_rag_async( + name=f"task-{uuid.uuid4()}", + index_name=index_name, + prompt=prompt, + glob_pattern=glob_pattern, + citation_mode=citation_mode, + folder_key=folder_key, + folder_path=None if folder_key else folder_path, + ) + return started, folder_key, folder_path + + +def build_glob_pattern( + folder_path_prefix: str | None, file_extension: str | None +) -> str: + """langchain's ``build_glob_pattern``: the DeepRAG file filter.""" + prefix = (folder_path_prefix or "").strip("/").rstrip("/*") or "**" + extension = f"*.{file_extension.lower()}" if file_extension else "*" + if prefix == "**": + return "**/*" if extension == "*" else f"**/{extension}" + return f"{prefix}/{extension}" + + +# -------------------------------------------------------------------------- +# factories +# -------------------------------------------------------------------------- +def uipath_context_tool( + name: str, + *, + index_name: str, + description: str | None = None, + folder_path: str | None = None, + number_of_results: int = 10, + threshold: float = 0.0, + query: str | None = None, + query_from_input: str | None = None, + folder_path_prefix: str | None = None, + folder_path_prefix_from_input: str | None = None, + file_extension: str | None = None, + include_system_indexes: bool | None = None, + tool_name: str | None = None, + timeout_sec: float | None = None, +) -> UiPathContextTool: + """An immediate tool that runs a semantic search on a Context Grounding index. + + Args: + name: Display name (``contextGroundingTool`` span); the model-facing + name is it sanitized the way low-code agents do, unless + ``tool_name`` is given. + index_name: The index searched (a bindable ``index`` resource). + description: Shown to the model; defaults to a sentence naming the + index. Say what the index holds: the model picks tools by it. + folder_path: Folder of the index; defaults to the job's folder. + number_of_results: Maximum number of chunks returned (default 10). + threshold: Minimum similarity score (default 0). + query: A fixed query: the model calls the tool with no arguments. + query_from_input: Path of the query in the agent input (``topic``, + ``a.b`` or ``{{topic}}``); the model's ``query`` is the fallback. + folder_path_prefix: Only search documents under this data-source + folder. + folder_path_prefix_from_input: Path of that prefix in the agent input. + file_extension: Only search documents with this extension; ``All`` + means no filter. + include_system_indexes: Fall back to tenant system indexes; defaults + to only in Studio (debug) runs. + tool_name: Model-facing name override. + timeout_sec: Host-enforced timeout; None means 300 seconds. + + Raises: + ValueError: An invalid combination (e.g. both ``query`` and + ``query_from_input``), an empty index name or query. + """ + return UiPathContextTool( + name=sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name), + description=description + or f"Searches the '{index_name}' knowledge base and returns the most " + "relevant passages.", + index_name=index_name, + folder_path=folder_path, + number_of_results=number_of_results, + threshold=threshold, + query=query, + query_from_input=query_from_input, + folder_path_prefix=folder_path_prefix, + folder_path_prefix_from_input=folder_path_prefix_from_input, + file_extension=file_extension, + include_system_indexes=include_system_indexes, + display_name=name, + timeout_sec=timeout_sec, + ) + + +def uipath_deep_rag_tool( + name: str, + *, + index_name: str, + description: str | None = None, + citation_mode: str = "Inline", + folder_path: str | None = None, + query: str | None = None, + query_from_input: str | None = None, + folder_path_prefix: str | None = None, + folder_path_prefix_from_input: str | None = None, + file_extension: str | None = None, + tool_name: str | None = None, +) -> UiPathDeepRagTool: + """A durable tool that runs a DeepRAG task on a Context Grounding index. + + The call suspends the run until the task ends (minutes, typically); the + model then gets ``{"text", "citations", "deepRagId"}``. + + Args: + name: Display name; the model-facing name is it sanitized the way + low-code agents do, unless ``tool_name`` is given. + index_name: The index researched (a bindable ``index`` resource). + description: Shown to the model; say what the index holds. + citation_mode: ``Inline`` (default) or ``Skip``. + folder_path: Folder of the index; defaults to the job's folder. + query: A fixed task prompt: the model calls the tool with no arguments. + query_from_input: Path of the prompt in the agent input. + folder_path_prefix: Only research documents under this folder. + folder_path_prefix_from_input: Path of that prefix in the agent input. + file_extension: Only research documents with this extension. + tool_name: Model-facing name override. + + Raises: + ValueError: An invalid combination, an empty index name or query, or + an unknown citation mode. + """ + return UiPathDeepRagTool( + name=sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name), + description=description + or f"Researches the '{index_name}' knowledge base across documents and " + "returns a synthesized answer with citations.", + index_name=index_name, + citation_mode=citation_mode, + folder_path=folder_path, + query=query, + query_from_input=query_from_input, + folder_path_prefix=folder_path_prefix, + folder_path_prefix_from_input=folder_path_prefix_from_input, + file_extension=file_extension, + display_name=name, + ) + + +def uipath_deep_rag_attachments_tool( + name: str = "DeepRAG", + *, + description: str | None = None, + citation_mode: str = "Inline", + query: str | None = None, + query_from_input: str | None = None, + query_description: str | None = None, + attachment_from_input: str | None = None, + folder_path: str | None = None, + tool_name: str | None = None, +) -> UiPathDeepRagAttachmentsTool: + """A durable tool that runs a DeepRAG task on a job attachment. + + The model passes an ``attachment`` (a JobAttachment it was given) and a + ``query``; the call suspends the run until the task ends (minutes, + typically), and the model then gets ``{"text", "citations", + "deepRagId"}``. DeepRAG reads PDF and TXT files. + + Args: + name: Display name; the model-facing name is it sanitized the way + low-code agents do, unless ``tool_name`` is given. + description: Shown to the model; defaults to Agent Builder's. + citation_mode: ``Inline`` (default) or ``Skip``. + query: A fixed task prompt: the model passes no ``query``. + query_from_input: Path of the prompt in the agent input; the model's + ``query`` is the fallback. + query_description: Description of the ``query`` argument. + attachment_from_input: Path of the attachment in the agent input. Its + value wins; ``attachment`` stays visible to the model but + optional, and the model's is used when the input has none. + folder_path: Folder the task runs in; defaults to the job's folder. + tool_name: Model-facing name override. + + Raises: + ValueError: An invalid combination, an empty query or an unknown + citation mode. + """ + from .escalation_tools import FixedArg + + fixed: dict[str, Any] = {} + if attachment_from_input: + fixed["attachment"] = FixedArg(path=_input_path(attachment_from_input)) + tool_label = ( + sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name) + ) + return UiPathDeepRagAttachmentsTool( + name=tool_label, + description=description or DEFAULT_DEEP_RAG_ATTACHMENTS_DESCRIPTION, + citation_mode=citation_mode, + query=query, + query_from_input=query_from_input, + query_description=query_description, + fixed_args=fixed, + folder_path=folder_path, + display_name=_lowcode_tool_name(name), + mock_name=name, + ) + + +def deep_rag_tool_from_resource( + resource: Any, + *, + tool_name: str | None = None, +) -> UiPathDeepRagAttachmentsTool: + """The DeepRAG tool from a low-code internal ``tool`` resource. + + Args: + resource: An ``AgentInternalToolResourceConfig``, or the resource dict + as found in a low-code ``agent.json`` (``"$resourceType": "tool"``, + ``"type": "Internal"``, ``properties.toolType`` ``"deep-rag"``). + Its query (static, dynamic or agent-input), citation mode and + static, argument and text-builder ``argumentProperties`` are used; + ``fileExtension`` and ``folderPathPrefix`` are ignored, as in + langchain. + tool_name: Model-facing name; defaults to the resource name sanitized + the way low-code agents do. + + Raises: + ValueError: The resource is disabled, is another internal tool, has a + static query without a value, or does not validate. + """ + from .attachment_tools import fixed_args_from_properties, internal_tool_resource + + resource, label = internal_tool_resource(resource, DEEP_RAG_TOOL_TYPE) + settings = resource.properties.settings + setting = settings.query + if ( + setting is not None + and str(_value(getattr(setting, "variant", "")) or "").lower() == "static" + and not str(getattr(setting, "value", None) or "").strip() + ): + raise ValueError( + f"{label}: the static query has no value. Set one, or make the query " + "dynamic so the model writes it." + ) + if setting is not None and not _value(getattr(setting, "variant", None)): + query, query_from_input = None, None # langchain: dynamic + else: + query, query_from_input = _query_setting(label, setting) + schema = copy.deepcopy(resource.input_schema or deep_rag_attachments_input_schema()) + display = _lowcode_tool_name(resource.name) + return UiPathDeepRagAttachmentsTool( + name=sanitize_tool_name(tool_name) if tool_name else display, + description=resource.description or DEFAULT_DEEP_RAG_ATTACHMENTS_DESCRIPTION, + input_json_schema=schema, + citation_mode=_value(settings.citation_mode.value), + query=query, + query_from_input=query_from_input, + query_description=getattr(setting, "description", None) if setting else None, + fixed_args=fixed_args_from_properties(label, resource.argument_properties), + display_name=display, + mock_name=resource.name, + mock_output_schema=_mock_output_schema(resource), + ) + + +def context_tool_from_resource( + resource: AgentContextResourceConfig | Mapping[str, Any], + *, + tool_name: str | None = None, + timeout_sec: float | None = None, +) -> UiPathContextTool | UiPathDeepRagTool: + """A tool from a low-code ``context`` resource. + + Retrieval mode ``Semantic`` or ``Structured`` gives an immediate search + tool (langchain answers both with a semantic search); ``DeepRAG`` gives a + durable :class:`UiPathDeepRagTool`, which needs ``settings.citationMode``. + + Args: + resource: An ``AgentContextResourceConfig``, or the resource dict as + found in a low-code ``agent.json`` (``resources[]`` entry with + ``"$resourceType": "context"``). + tool_name: Model-facing name; defaults to the resource name sanitized + the way low-code agents do. + timeout_sec: Host-enforced timeout; None means 300 seconds. + + Raises: + ValueError: The resource is disabled, is not an index resource, has + no index name, uses a retrieval mode not supported yet + (BatchTransform, DataFabric), is DeepRAG without a citation mode, + or does not validate. + """ + from uipath.agent.models.agent import ( + AgentContextResourceConfig, + AgentContextType, + ) + + if isinstance(resource, Mapping): + data = dict(resource) + data.setdefault("$resourceType", "context") + try: + resource = AgentContextResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid context resource: {e}") from e + label = f"Context resource '{resource.name}'" + if resource.is_enabled is False: + raise ValueError(f"{label} is disabled; filter it out before building tools.") + if resource.context_type == AgentContextType.ATTACHMENTS: + raise ValueError( + f"{label}: context type 'attachments' is not supported; use an " + "internal 'deep-rag' tool (deep_rag_tool_from_resource) or " + "'batch-transform' tool (batch_transform_tool_from_resource) on job " + "attachments." + ) + if resource.context_type not in (None, AgentContextType.INDEX): + raise ValueError( + f"{label}: context type '{_value(resource.context_type)}' is not " + "supported yet (only index resources are)." + ) + if not resource.index_name: + raise ValueError(f"{label} has no 'indexName'.") + settings = resource.settings + if settings is None: + raise ValueError(f"{label} has no 'settings'.") + mode = str(_value(settings.retrieval_mode)) + supported = (*SEARCH_MODES, "DeepRAG") + if mode.lower() not in {m.lower() for m in supported}: + raise ValueError( + f"{label}: retrieval mode '{mode}' is not supported yet " + f"(supported: {', '.join(supported)})." + ) + query, query_from_input = _query_setting(label, settings.query) + prefix, prefix_from_input = _prefix_setting(settings.folder_path_prefix) + extension = settings.file_extension.value if settings.file_extension else None + if mode.lower() == "deeprag": + citation = settings.citation_mode.value if settings.citation_mode else None + if not citation: + raise ValueError(f"{label}: DeepRAG needs 'settings.citationMode'.") + return UiPathDeepRagTool( + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=resource.description + or f"Researches the '{resource.index_name}' knowledge base.", + index_name=resource.index_name, + citation_mode=citation, + query=query, + query_from_input=query_from_input, + folder_path_prefix=prefix, + folder_path_prefix_from_input=prefix_from_input, + file_extension=str(extension) if extension else None, + retrieval_mode="DeepRAG", + display_name=resource.name, + mock_name=resource.name, + ) + return UiPathContextTool( + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=resource.description + or f"Searches the '{resource.index_name}' knowledge base and returns the " + "most relevant passages.", + index_name=resource.index_name, + number_of_results=settings.result_count, + threshold=settings.threshold, + query=query, + query_from_input=query_from_input, + folder_path_prefix=prefix, + folder_path_prefix_from_input=prefix_from_input, + file_extension=str(extension) if extension else None, + retrieval_mode=mode, + display_name=resource.name, + timeout_sec=timeout_sec, + mock_name=resource.name, + ) + + +def _mock_output_schema(resource: Any) -> dict[str, Any] | None: + """The resource's ``outputSchema`` when it declares fields (eval mocks).""" + schema = getattr(resource, "output_schema", None) + if isinstance(schema, dict) and schema.get("properties"): + return copy.deepcopy(schema) + return None + + +def _query_setting(label: str, setting: Any) -> tuple[str | None, str | None]: + """``(static query, agent-input path)`` of a ``settings.query``. + + Like langchain: a missing setting is dynamic; a setting without a variant + or a static one without a value is refused; any other variant + (``textBuilder``, ``arrayBuilder``) is dynamic. + """ + if setting is None: + return None, None + variant = _value(getattr(setting, "variant", None)) + if not variant: + raise ValueError(f"{label}: the query has no variant.") + variant = str(variant).lower() + value = getattr(setting, "value", None) + if variant == "static": + if not value or not str(value).strip(): + raise ValueError(f"{label}: the static query has no value.") + return str(value), None + if variant == "argument" and value: + return None, str(value) + return None, None + + +def _prefix_setting(setting: Any) -> tuple[str | None, str | None]: + if setting is None or not getattr(setting, "value", None): + return None, None + variant = str(_value(getattr(setting, "variant", None)) or "").lower() + if variant == "static": + return str(setting.value), None + if variant == "argument": + return None, str(setting.value) + return None, None + + +def _input_path(path: str | None) -> str | None: + """``{{a.b}}`` / ``a.b`` → ``a.b``; empty → None.""" + if path is None: + return None + stripped = path.strip().strip("{}").strip() + return stripped or None + + +def _extension(ext: str | None) -> str | None: + """``All`` (any case) and empty mean no filter.""" + if not ext or not ext.strip() or ext.strip().lower() == "all": + return None + return ext.strip().lstrip(".") + + +def _value(enum_or_str: Any) -> Any: + return getattr(enum_or_str, "value", enum_or_str) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/escalation_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/escalation_tools.py new file mode 100644 index 00000000..16eb3b9d --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/escalation_tools.py @@ -0,0 +1,975 @@ +"""Action Center escalations: durable tools that ask a human. + +An escalation tool creates an Action Center task with an app (a form) and +**suspends** the run until a person completes it. The run then resumes, +possibly on another robot, and the model gets langchain's escalation result: +``{"outcome": , "output": , +"task_id": ..., "assigned_to": ...}``. + +Two ways to declare one: + +* :func:`uipath_escalation_tool`: coded; +* :func:`escalation_tool_from_resource`: an ``escalation`` resource copied + from a low-code ``agent.json`` (its first, ``actionCenter`` channel). + +Typical use:: + + from pydantic import BaseModel + from uipath_codex_sdk import UiPathCodexAgent, uipath_escalation_tool + + class Review(BaseModel): + amount: float + reason: str + + agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Approve the expense {expense}; ask a manager above 1000.", + tools=[ + uipath_escalation_tool( + "ExpenseApproval", # the deployed Action Center app + folder_path="Shared", + input_schema=Review, + assignee="manager@example.com", + outcome_mapping={"Reject": "end"}, + ) + ], + ) + +The app is a bindable ``app`` resource (``app.[.]``). +""" + +from __future__ import annotations + +import copy +import json +import logging +import os +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Mapping + +from pydantic import BaseModel, ValidationError + +from . import tools as _tools +from ._fixed_args import ( # noqa: F401 (FixedArg, _without_fields: re-exported) + FixedArg, + _get_path, + _without_fields, + fixed_args_from_code, + fixed_args_from_properties, + fixed_args_schema, + missing_args_message, + missing_from_input, + with_fixed_args, +) +from .integration_tools import _lowcode_tool_name +from .runtime.errors import UiPathRunTerminatedError +from .tools import ( + _PERMISSIVE_SCHEMA, + DurableToolContext, + UiPathDurableToolError, + UiPathDurableToolPending, + _schema_from, + _validate_against_schema, + sanitize_tool_name, +) + +if TYPE_CHECKING: + from uipath.agent.models.agent import AgentEscalationResourceConfig + from uipath.platform.action_center.tasks import TaskRecipient + + from .runtime.mocking import MockSpan, MockSpec + +logger = logging.getLogger(__name__) + +__all__ = [ + "UiPathEscalationTool", + "escalation_tool_from_resource", + "uipath_escalation_tool", +] + +DEFAULT_TITLE = "Escalation Task" +DELETED_OUTCOME = "The escalation task was deleted" +_PENDING_STATUSES = frozenset({0, 1, "0", "1", "unassigned", "pending"}) +#: Keys that only a serialized ``Task`` has (a raw human answer has none). +_TASK_KEYS = frozenset({"taskDefinitionPropertiesId", "isDeleted", "is_deleted"}) + + +# -------------------------------------------------------------------------- +# the tool +# -------------------------------------------------------------------------- +@dataclass +class UiPathEscalationTool: + """A durable tool that creates an Action Center task and waits for it. + + Prefer :func:`uipath_escalation_tool` or + :func:`escalation_tool_from_resource` over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + app_name: Deployed Action Center app (a bindable ``app`` resource). + folder_path: Folder of the app; None means the job's folder. + folder_key: Folder key of the app, instead of ``folder_path``. + app_key: App key; skips the deployed-app lookup. + app_input_schema: JSON schema of the app's inputs (task data). + output_json_schema: JSON schema of the app's outputs: the task data + the model gets back is filtered to these fields. + input_model: Pydantic model the task data is validated with. + title: Fixed task title. + title_from_input: Path of the title in the agent input. + title_tokens: Text tokens of the title (agent.json text builder). + recipient: Who the task is assigned to. + recipient_config: An agent.json recipient, resolved at call time. + assignee: User name or email the task is assigned to. + priority: ``Low`` / ``Medium`` / ``High`` / ``Critical``. + labels: Task labels. + outcome_mapping: Outcome (button) → ``continue`` or ``end``; an + ``end`` outcome ends the run. + fixed_args: Pre-filled app fields by path (``a.b``, ``x[*].y``): + static ones are hidden from the model, agent-input ones stay + visible but optional; merged into the task data. + is_actionable_message_enabled: Actionable notifications. + actionable_message_metadata: Their metadata. + display_name: Name of the ``escalationTool`` span; defaults to the + app name. + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span. + span_output_keys: Keys of the result the spans record (low-code's). + mock_name: Name the tool is simulated by in evals; None means the + model-facing name, lowercased (langchain's). + example_calls: Example calls for the eval LLM mocker (the channel's). + """ + + name: str + description: str + app_name: str + folder_path: str | None = None + folder_key: str | None = None + app_key: str | None = None + app_input_schema: dict[str, Any] = field( + default_factory=lambda: dict(_PERMISSIVE_SCHEMA) + ) + output_json_schema: dict[str, Any] | None = None + input_model: type[BaseModel] | None = None + title: str | None = None + title_from_input: str | None = None + title_tokens: list[Any] | None = None + recipient: TaskRecipient | None = None + recipient_config: Any = None + assignee: str | None = None + priority: str | None = None + labels: list[str] | None = None + outcome_mapping: dict[str, str] | None = None + fixed_args: dict[str, FixedArg] = field(default_factory=dict) + is_actionable_message_enabled: bool | None = None + actionable_message_metadata: dict[str, Any] | None = None + display_name: str | None = None + kind: str = "escalation" + tool_type: str = "Escalation" + span_output_keys: tuple[str, ...] = ("outcome", "output") + mock_name: str | None = None + example_calls: list[Any] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.app_name: + raise ValueError(f"Escalation tool '{self.name}': app_name is required.") + if self.folder_path and self.folder_key: + raise ValueError( + f"Escalation tool '{self.name}': set folder_path or folder_key, " + "not both." + ) + if sum(x is not None for x in (self.title, self.title_from_input)) > 1: + raise ValueError( + f"Escalation tool '{self.name}': set title or title_from_input, " + "not both." + ) + for outcome, action in (self.outcome_mapping or {}).items(): + if str(action).lower() not in ("continue", "end"): + raise ValueError( + f"Escalation tool '{self.name}': outcome '{outcome}' maps to " + f"{action!r}; use 'continue' or 'end'." + ) + if self.title_from_input: + self.title_from_input = self.title_from_input.strip().strip("{}").strip() + if self.recipient is not None and not self.recipient.display_name: + # assignedTo on the span; the SDK only fills it for id recipients. + self.recipient.display_name = self.recipient.value + + # --- UiPathDurableTool ------------------------------------------------ + + @property + def input_schema(self) -> dict[str, Any]: + """The app's inputs as the model sees them (pre-filled fields hidden).""" + return fixed_args_schema(self.app_input_schema, self.fixed_args) + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + _validate_against_schema(self.input_schema, args) + return json.loads(json.dumps(args, default=str)) + + def started_output(self, value: Any) -> dict[str, Any]: + task = getattr(value, "action", None) + task_id = getattr(task, "id", None) + return { + "status": "started", + "task_id": task_id, + "task_key": getattr(task, "key", None), + "task_url": task_url(task_id), + } + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Create the task; return ``WaitEscalation`` for it. + + The task is created here rather than by the resume trigger + (``CreateEscalation``), as langchain does, so the span carries its id + and URL and a rebuilt trigger never creates a second task. It is + created unassigned and then assigned: an assignment failure is logged + and the run still waits on the task (no duplicate on a retry). + + Raises: + UiPathDurableToolError: The task data is invalid, the recipient + cannot be resolved, or the task cannot be created. + """ + from opentelemetry import trace + from uipath.platform.common import WaitEscalation + + from .runtime.telemetry import ( + fail_span, + hold_span, + start_escalation_tool_span, + ) + + data = self.build_data(args, ctx.agent_input) + title = self.resolve_title(ctx.agent_input) + try: + recipient = await self.resolve_recipient(ctx.agent_input) + except ValueError as e: + raise UiPathDurableToolError( + f"Could not resolve who to assign the escalation of tool " + f"'{self.name}' to: {e}" + ) from e + display = self.display_name or self.app_name + span = start_escalation_tool_span(display, arguments=data) + try: + with trace.use_span(span, end_on_exit=False): + task, app_name, folder_path, folder_key = await _create_task( + self.app_name, + self.folder_path + or ( + None + if self.folder_key + else os.environ.get("UIPATH_FOLDER_PATH") + ), + requested_folder_path=self.folder_path, + folder_key=self.folder_key, + title=title, + data=data, + app_key=self.app_key, + priority=self.priority, + labels=self.labels or None, + is_actionable_message_enabled=self.is_actionable_message_enabled, + actionable_message_metadata=self.actionable_message_metadata, + ) + except BaseException as e: + fail_span(span, e) + span.end() + raise UiPathDurableToolError(self._create_error_message(e)) from e + span.set_attribute("taskId", str(task.id)) + url = task_url(task.id) + if url: + span.set_attribute("taskUrl", url) + if recipient is not None or self.assignee: + assigned = await _assign_task(task, recipient, self.assignee) + if assigned is None: + shown = (recipient.display_name if recipient else None) or self.assignee + if shown: + span.set_attribute("assignedTo", shown) + else: + span.set_attribute("assignmentError", assigned) + hold_span(span, output_attribute="result", output_keys=self.span_output_keys) + logger.info( + "escalation tool %s created task %s (call %s)", + self.name, + task.id, + ctx.call_id, + ) + return WaitEscalation( + action=task, + app_name=app_name, + recipient=recipient, + app_folder_path=folder_path, + app_folder_key=None if folder_path else folder_key, + ) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """Map the completed task (or a raw resume answer) to the result. + + * completed → ``{"status": "completed", "outcome", "output", + "task_id", "assigned_to"}``; an outcome mapped to ``end`` ends the + run (``TERMINATION_ESCALATION_REJECTED``), as in langchain; + * deleted → ends the run too (langchain's ``end``); + * still open → :class:`UiPathDurableToolPending`. + + A raw answer (``uipath run --resume '{...}'``) is the task's data, or + ``{"action": ..., "data": {...}}``. + """ + task = _task_from(resume_value) + task_id = task.id + if task.is_deleted: + result = { + "status": "deleted", + "outcome": DELETED_OUTCOME, + "output": None, + "task_id": task_id, + } + raise UiPathRunTerminatedError( + "Agent run ended because the escalation task was deleted", + f"Escalation task {task_id} of tool '{self.name}' was deleted.", + json.dumps(result), + ) + status = task.status + if (str(status).lower() if isinstance(status, str) else status) in ( + _PENDING_STATUSES + ): + raise UiPathDurableToolPending(f"task {task_id} is not completed yet") + outcome = task.action + output = filter_task_data( + task.data or {}, self.app_input_schema, self.output_json_schema + ) + result = { + "status": "completed", + "outcome": outcome, + "output": output, + "task_id": task_id, + "assigned_to": _completed_by(task), + } + text = json.dumps(result, ensure_ascii=False, default=str) + if self.outcome_action(outcome) == "end": + raise UiPathRunTerminatedError( + f"Agent run ended based on escalation outcome {outcome} with " + "directive end", + f"Escalation output: {json.dumps(output, default=str)}", + text, + ) + return text + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's ``EscalationToolOutput``: ``{action, data, is_deleted}``.""" + from .runtime.mocking import MockSpec + + return MockSpec( + name=self.mock_name or self.name.lower(), + description=self.description, + input_schema=self.input_schema, + output_schema={ + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["approve", "reject"]}, + "data": copy.deepcopy(self.output_json_schema) + or {"type": "object"}, + "is_deleted": {"type": "boolean", "default": False}, + }, + "required": ["action", "data"], + }, + example_calls=list(self.example_calls), + ) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan: + """The ``escalationTool`` child, without a task. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled (before the child opens, as + a real start fails before its span). + """ + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_escalation_tool_span + + span = start_escalation_tool_span( + self.display_name or self.app_name, + arguments=self._merged(args, getattr(ctx, "agent_input", None)), + ) + return MockSpan(span, "result", self.span_output_keys) + + def mock_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The model's arguments with the pre-filled fields, as the mocker sees them. + + langchain's ``StaticArgsHandler`` merges them into the tool call + before its mocked function runs, so mockito behaviors match on them. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled. + """ + return self._merged(args, getattr(ctx, "agent_input", None)) + + #: LangChain never resolves an escalation's job attachments (its + #: ``escalation_wrapper`` has no ``job_attachment_wrapper``): the host + #: checks only the model's own attachment values the pre-filled fields + #: leave in place, never a pre-filled one. + checks_prefilled_attachments: ClassVar[bool] = False + + @property + def prefilled_schema(self) -> dict[str, Any]: + """The app's inputs, pre-filled fields included (listed on the span).""" + return self.app_input_schema + + def prefilled_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled fields, unvalidated. + + A model attachment one of these replaces is not checked by the host + (see ``checks_prefilled_attachments``). + """ + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, agent_input) + + def span_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The ``Tool call`` span's input: the arguments with the pre-filled fields. + + As low-code's, whose tool call carries them once merged. + """ + return self._with_fixed(args, getattr(ctx, "agent_input", None)) + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """The simulated answer as a completed task, through :meth:`finish`. + + So the output filter, a deleted task and an outcome mapped to ``end`` + work as for a real task (the outcome is matched as given). + + Raises: + UiPathDurableToolError: The simulated value is not an object. + UiPathRunTerminatedError: The task is deleted, or its outcome ends + the run. + """ + from uipath.platform.action_center import Task + + if not isinstance(value, dict): + raise UiPathDurableToolError( + f"Unexpected simulated result of tool '{self.name}': {str(value)[:200]}" + ) + data = value.get("data") + task = Task.model_validate( + { + "status": 2, + "action": value.get("action"), + "data": data if isinstance(data, dict) else {}, + "isDeleted": bool(value.get("is_deleted")), + } + ) + return await self.finish(args, task) + + # --- helpers ---------------------------------------------------------- + + def outcome_action(self, outcome: str | None) -> Literal["continue", "end"]: + action = (self.outcome_mapping or {}).get(outcome or "") + return "end" if str(action or "").lower() == "end" else "continue" + + def build_data( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The task data: the model's arguments with the pre-filled fields. + + An agent-input field the input has no value for keeps the model's + value, as in langchain. Validated with ``input_model`` once merged, + when there is one. + + Raises: + UiPathDurableToolError: A required agent-input field neither the + agent input nor the model filled, or the merged data does not + validate. + """ + data = self._merged(args, agent_input) + if self.input_model is not None: + try: + data = self.input_model.model_validate(data).model_dump(mode="json") + except ValidationError as e: + raise UiPathDurableToolError( + f"Invalid task data for tool '{self.name}': {e}" + ) from e + return json.loads(json.dumps(data, default=str)) + + def _with_fixed( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + return with_fixed_args(args, self.fixed_args, agent_input) + + def _merged( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """:meth:`_with_fixed`, refusing a required field still missing.""" + data = self._with_fixed(args, agent_input) + missing = missing_from_input(self.app_input_schema, self.fixed_args, data) + if missing: + raise UiPathDurableToolError(missing_args_message(self.name, missing)) + return data + + def resolve_title(self, agent_input: dict[str, Any] | None) -> str: + if self.title_from_input: + found, value = _get_path(agent_input, self.title_from_input) + if found and value not in (None, ""): + return str(value) + if self.title_tokens is not None: + from uipath.agent.utils.text_tokens import build_string_from_tokens + + return build_string_from_tokens(self.title_tokens, agent_input or {}) + return self.title or DEFAULT_TITLE + + async def resolve_recipient( + self, agent_input: dict[str, Any] | None + ) -> TaskRecipient | None: + if self.recipient is not None: + return self.recipient.model_copy() + if self.recipient_config is not None: + return await resolve_recipient_config(self.recipient_config, agent_input) + return None + + def _create_error_message(self, e: BaseException) -> str: + info = getattr(e, "error_info", None) + message = (getattr(info, "message", None) if info is not None else None) or str( + e + ).strip() + status = getattr(e, "status_code", None) + where = f" (HTTP {status})" if status else "" + logger.error( + "escalation tool %s could not create its task%s: %s", + self.name, + where, + message, + ) + return ( + f"Could not create the Action Center task of tool '{self.name}' " + f"(app '{self.app_name}'){where}: {message}" + ) + + +# -------------------------------------------------------------------------- +# tasks +# -------------------------------------------------------------------------- +def _resource_override_app(func: Any) -> Any: + try: + from uipath.platform.common import resource_override + except Exception: # pragma: no cover - platform missing + return func + return resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + )(func) + + +@_resource_override_app +async def _create_task( + app_name: str, + app_folder_path: str | None = None, + *, + requested_folder_path: str | None, + folder_key: str | None, + title: str, + data: dict[str, Any], + app_key: str | None, + priority: str | None, + labels: list[str] | None, + is_actionable_message_enabled: bool | None, + actionable_message_metadata: dict[str, Any] | None, +) -> tuple[Any, str, str | None, str | None]: + """``tasks.create_async``, unassigned; ``(task, app, folder path, key)``. + + ``app_name`` and ``app_folder_path`` go through the SDK's ``app`` resource + override first (this function carries it), so the values the task was + created with are the ones saved for the resume. Exactly one folder field + is sent (the SDK refuses both): an explicit key, unless a binding moved + the app to another folder path. + """ + client = _tools._uipath_client() + use_key = ( + folder_key if folder_key and app_folder_path == requested_folder_path else None + ) + folder_path = None if use_key else app_folder_path + task = await client.tasks.create_async( + title, + data, + app_name=app_name, + app_key=app_key, + app_folder_path=folder_path, + app_folder_key=use_key, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + ) + return task, app_name, folder_path, use_key + + +async def _assign_task( + task: Any, recipient: TaskRecipient | None, assignee: str | None +) -> str | None: + """Assign the task (the SDK's AssignTasks call); None, or the error text.""" + try: + from uipath.platform.action_center._tasks_service import _assign_task_spec + + service = _tools._uipath_client().tasks + spec = await _assign_task_spec(service, task.id, assignee, recipient) + await service.request_async( + spec.method, spec.endpoint, json=spec.json, content=spec.content + ) + return None + except Exception as e: + message = str(e).strip() or type(e).__name__ + logger.warning( + "Escalation task %s was created but could not be assigned; it stays " + "unassigned in Action Center: %s", + task.id, + message, + ) + return message + + +def task_url(task_id: Any) -> str | None: + """Action Center URL of a task (``/actions_/tasks/``).""" + base = os.environ.get("UIPATH_URL") + if not base or task_id is None: + return None + return f"{base.rstrip('/')}/actions_/tasks/{task_id}" + + +def _task_from(value: Any) -> Any: + """``value`` as a ``Task``: one, its serialized form, or a raw answer. + + A serialized task is recognised by keys only a task has (or ``key`` and + ``status`` together); ``{"action": ..., "data": {...}}`` is a typed answer; + anything else is the answer's form data. + """ + from uipath.platform.action_center import Task + + if isinstance(value, Task): + return value + if isinstance(value, dict): + keys = set(value) + if keys & _TASK_KEYS or {"key", "status"} <= keys: + return Task.model_validate(value) + if "data" in keys and keys <= {"action", "data", "status"}: + return Task.model_validate({"status": 2, **value}) + return Task.model_validate({"status": 2, "data": value}) + return Task.model_validate({"status": 2, "data": {"value": value}}) + + +def _completed_by(task: Any) -> str | None: + user = task.completed_by_user or task.assigned_to_user + if isinstance(user, dict): + return user.get("emailAddress") or user.get("userName") or user.get("name") + return None + + +def filter_task_data( + data: dict[str, Any], + input_schema: dict[str, Any], + output_schema: dict[str, Any] | None, +) -> dict[str, Any]: + """langchain's ``_parse_task_data``: the output fields of the task data. + + With an output schema, its fields; without one, every field that is not an + input field. + """ + if output_schema and output_schema.get("properties") is not None: + keep = set(output_schema["properties"]) + return {k: v for k, v in data.items() if k in keep} + inputs = set((input_schema or {}).get("properties") or {}) + return {k: v for k, v in data.items() if k not in inputs} + + +async def resolve_recipient_config( + config: Any, agent_input: dict[str, Any] | None +) -> TaskRecipient | None: + """langchain's ``resolve_recipient_value`` for an agent.json recipient. + + Raises: + ValueError: An asset or agent-input value is missing. + """ + from uipath.agent.models.agent import ( + AgentEscalationRecipientType as Kind, + ) + from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType + + kind = str(getattr(getattr(config, "type", None), "value", config.type)) + + def make(value: Any, type_: TaskRecipientType) -> TaskRecipient: + return TaskRecipient(type=type_, value=str(value), displayName=str(value)) + + if kind in (Kind.ASSET_USER_EMAIL.value, Kind.ASSET_GROUP_NAME.value): + folder = getattr(config, "folder_path", None) or os.environ.get( + "UIPATH_FOLDER_PATH" + ) + try: + asset = await _tools._uipath_client().assets.retrieve_async( + name=config.asset_name, folder_path=folder + ) + except Exception as e: + raise ValueError( + f"could not read asset '{config.asset_name}' in folder '{folder}': {e}" + ) from e + if not asset or not getattr(asset, "value", None): + raise ValueError(f"asset '{config.asset_name}' has no value") + return make( + asset.value, + TaskRecipientType.EMAIL + if kind == Kind.ASSET_USER_EMAIL.value + else TaskRecipientType.GROUP_NAME, + ) + if kind in (Kind.ARGUMENT_EMAIL.value, Kind.ARGUMENT_GROUP_NAME.value): + found, value = _get_path(agent_input, config.argument_path) + if not found or value in (None, ""): + raise ValueError( + f"the agent input has no value for '{config.argument_path}'" + ) + return make( + value, + TaskRecipientType.EMAIL + if kind == Kind.ARGUMENT_EMAIL.value + else TaskRecipientType.GROUP_NAME, + ) + if kind == Kind.WORKLOAD.value: + return make(config.display_name, TaskRecipientType.WORKLOAD) + if kind == Kind.ROUND_ROBIN.value: + return make(config.display_name, TaskRecipientType.ROUND_ROBIN) + if kind in ( + Kind.USER_ID.value, + Kind.GROUP_ID.value, + Kind.USER_EMAIL.value, + Kind.GROUP_NAME.value, + ): + type_ = ( + TaskRecipientType.EMAIL + if kind == Kind.USER_EMAIL.value + else TaskRecipientType(kind) + ) + return TaskRecipient( + type=type_, + value=config.value, + displayName=getattr(config, "display_name", None) or config.value, + ) + logger.warning( + "Escalation recipient type %r is not supported; the task is not assigned.", + kind, + ) + return None + + +# -------------------------------------------------------------------------- +# factories +# -------------------------------------------------------------------------- +def _description( + description: str | None, app_name: str, output_schema: dict[str, Any] | None +) -> str: + text = description or ( + f"Asks a person to review this in UiPath Action Center (app '{app_name}') " + "and waits for their decision. Returns the reviewer's outcome and the " + "completed form fields." + ) + if output_schema and output_schema.get("properties"): + compact = json.dumps( + {"type": "object", "properties": output_schema["properties"]}, + separators=(",", ":"), + ) + if len(compact) <= 4000: + text += " The form fields returned match: " + compact + return text + + +def uipath_escalation_tool( + app_name: str, + *, + folder_path: str | None = None, + folder_key: str | None = None, + app_key: str | None = None, + description: str | None = None, + input_schema: type[BaseModel] | dict[str, Any] | None = None, + output_schema: type[BaseModel] | dict[str, Any] | None = None, + title: str | None = None, + title_from_input: str | None = None, + recipient: TaskRecipient | None = None, + assignee: str | None = None, + priority: str | None = None, + labels: list[str] | None = None, + outcome_mapping: Mapping[str, str] | None = None, + static_args: Mapping[str, Any] | None = None, + args_from_input: Mapping[str, str] | None = None, + is_actionable_message_enabled: bool | None = None, + actionable_message_metadata: dict[str, Any] | None = None, + display_name: str | None = None, + tool_name: str | None = None, +) -> UiPathEscalationTool: + """A durable tool that asks a person through an Action Center app. + + Args: + app_name: The deployed Action Center app (a bindable ``app`` resource). + folder_path: Folder of the app; defaults to the job's folder. + folder_key: Folder key of the app, instead of ``folder_path``. + app_key: App key; skips the deployed-app lookup. + description: Shown to the model: say when to escalate. + input_schema: The app's inputs (pydantic model or JSON schema); the + model fills them, except the pre-filled ones. + output_schema: The app's outputs: the form data the model gets back + is filtered to these fields (default: every non-input field). + title: Task title (default "Escalation Task"). + title_from_input: Path of the title in the agent input. + recipient: A ``TaskRecipient`` (user/group by id, email or name, + workload, round robin). + assignee: User name or email, instead of ``recipient``. + priority: ``Low`` / ``Medium`` / ``High`` / ``Critical``. + labels: Task labels. + outcome_mapping: Outcome (the app's button) → ``continue`` or ``end``; + an ``end`` outcome ends the run. + static_args: Fixed app inputs (``a.b``, ``x[*].y``), hidden from the + model. + args_from_input: App inputs read from the agent input (field → path). + They stay visible to the model but optional: the agent input's + value wins, and the model's is used when the input has none. + is_actionable_message_enabled: Actionable notifications. + actionable_message_metadata: Their metadata. + display_name: Name of the ``escalationTool`` span; defaults to + ``app_name``. + tool_name: Model-facing name; defaults to ``escalate_``. + + Raises: + ValueError: An invalid combination or outcome mapping. + """ + if recipient is not None and assignee: + raise ValueError("set recipient or assignee, not both") + model = ( + input_schema + if isinstance(input_schema, type) and issubclass(input_schema, BaseModel) + else None + ) + output_json = _schema_from(output_schema) if output_schema is not None else None + fixed = fixed_args_from_code(static_args, args_from_input) + return UiPathEscalationTool( + name=sanitize_tool_name(tool_name) + if tool_name + else f"escalate_{_lowcode_tool_name(app_name)}", + description=_description(description, app_name, output_json), + app_name=app_name, + folder_path=folder_path, + folder_key=folder_key, + app_key=app_key, + app_input_schema=_schema_from(input_schema), + output_json_schema=output_json, + input_model=model, + title=title, + title_from_input=title_from_input, + recipient=recipient, + assignee=assignee, + priority=priority, + labels=list(labels) if labels else None, + outcome_mapping=dict(outcome_mapping) if outcome_mapping else None, + fixed_args=fixed, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + display_name=display_name or app_name, + ) + + +def escalation_tool_from_resource( + resource: AgentEscalationResourceConfig | Mapping[str, Any], + *, + tool_name: str | None = None, +) -> UiPathEscalationTool: + """A durable tool from a low-code ``escalation`` resource. + + Uses the resource's first channel, which must be an ``actionCenter`` app + channel (QuickForm channels are not supported yet). It maps the app, its + folder, input/output schemas, task title (text, text builder or agent + input), first recipient, priority, labels, outcome mapping and the + static / argument / text-builder / array-builder ``argumentProperties`` + (keys may be JSONPath, ``$['a']``). Escalation memory is not supported: + a memory-enabled resource gets a warning. + + Raises: + ValueError: The resource is disabled, not an Action Center app + escalation, has no app name, or does not validate. + """ + from uipath.agent.models.agent import ( + AgentEscalationChannel, + AgentEscalationResourceConfig, + AgentIxpVsEscalationResourceConfig, + DynamicTaskTitle, + TextBuilderTaskTitle, + ) + + # escalationType 1 (Document Validation Task) is another tool, as in + # langchain's tool factory. + if isinstance(resource, AgentIxpVsEscalationResourceConfig) or ( + isinstance(resource, Mapping) + and (resource.get("escalationType") or resource.get("escalation_type")) == 1 + ): + from .ixp_tools import vs_escalation_tool_from_resource + + return vs_escalation_tool_from_resource(resource, tool_name=tool_name) # type: ignore[return-value] + if isinstance(resource, Mapping): + data = dict(resource) + data.setdefault("$resourceType", "escalation") + try: + resource = AgentEscalationResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid escalation resource: {e}") from e + label = f"Escalation resource '{resource.name}'" + if resource.is_enabled is False: + raise ValueError(f"{label} is disabled; filter it out before building tools.") + if not resource.channels: + raise ValueError(f"{label} has no channels.") + channel = resource.channels[0] + if not isinstance(channel, AgentEscalationChannel): + raise ValueError( + f"{label}: channel type '{getattr(channel, 'type', None)}' is not " + "supported yet (only Action Center app channels are)." + ) + props = channel.properties + if not props.app_name: + raise ValueError(f"{label} has no 'appName'.") + if resource.is_agent_memory_enabled: + logger.warning( + "%s has escalation memory enabled; it is not supported and is ignored.", + label, + ) + title: str | None = None + title_from_input: str | None = None + title_tokens: list[Any] | None = None + if isinstance(channel.task_title, TextBuilderTaskTitle): + title_tokens = list(channel.task_title.tokens) + elif isinstance(channel.task_title, DynamicTaskTitle): + title_from_input = channel.task_title.argument_path + elif isinstance(channel.task_title, str): + title = channel.task_title + fixed = fixed_args_from_properties(label, channel.argument_properties) + output_schema = channel.output_schema or None + metadata = props.actionable_message_meta_data + return UiPathEscalationTool( + name=sanitize_tool_name(tool_name) + if tool_name + else f"escalate_{_lowcode_tool_name(resource.name)}", + description=_description( + resource.description or channel.description, props.app_name, output_schema + ), + app_name=props.app_name, + folder_path=props.folder_name, + app_input_schema=dict(channel.input_schema or _PERMISSIVE_SCHEMA), + output_json_schema=output_schema, + title=title, + title_from_input=title_from_input, + title_tokens=title_tokens, + recipient_config=channel.recipients[0] if channel.recipients else None, + priority=channel.priority, + labels=list(channel.labels) or None, + outcome_mapping=dict(channel.outcome_mapping or {}) or None, + fixed_args=fixed, + is_actionable_message_enabled=props.is_actionable_message_enabled, + actionable_message_metadata=metadata if isinstance(metadata, dict) else None, + display_name=props.app_name or channel.name, + example_calls=list(props.example_calls or []), + ) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/integration_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/integration_tools.py new file mode 100644 index 00000000..b0e0551d --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/integration_tools.py @@ -0,0 +1,862 @@ +"""Integration Service activities as immediate tools. + +An Integration Service (IS) activity is one HTTP operation of a connector +(e.g. GenAI Activities ``WebSearch``, ``POST /v2/webSearch``) run against a +connection. The runtime serves it to Codex on the ``uipath_tools`` MCP server +and answers each call as soon as +``UiPath().connections.invoke_activity_async(...)`` returns, the same call +low-code agents make (``uipath_langchain``'s ``create_integration_tool``). The +run never suspends on it. + +Two ways to declare one, both ending in the same ``ActivityMetadata``: + +* :func:`uipath_integration_tool`: coded, with fields named after the + low-code resource (``tool_path``, ``method``, ``parameters``, + ``body_structure``) and parameter locations inferred where unambiguous; +* :func:`integration_tool_from_resource`: an ``Integration`` resource copied + from a low-code ``agent.json`` (or an ``AgentIntegrationToolResourceConfig``). + +Typical use:: + + from pydantic import BaseModel + from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool + + class WebSearchIn(BaseModel): + query: str + + agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Research {topic}.", + tools=[ + uipath_integration_tool( + "web_search", + connection_id="", + tool_path="/v2/webSearch", + input_schema=WebSearchIn, + static_args={"provider": "GoogleCustomSearch"}, + ) + ], + ) + +The connection id is passed to the SDK unchanged on every call, so a +``connection.`` resource overwrite (``bindings.json``) rebinds it. +""" + +from __future__ import annotations + +import copy +import json +import logging +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, Mapping, Optional, Sequence + +from pydantic import BaseModel, Field, ValidationError, create_model + +from . import tools as _tools +from ._fixed_args import ( # noqa: F401 (re-exported: existing imports keep working) + _SEGMENT_RE, + _get_path, + _hide_static, + _navigate, + _note_arguments, + _parent_and_key, + _resolve_ref, + _segments, + _set_path, + _set_segments, + _unwrap_any_of, +) +from .tools import ( + _PERMISSIVE_SCHEMA, + ToolContext, + UiPathToolError, + _schema_from, + _validate_against_schema, + sanitize_tool_name, +) + +if TYPE_CHECKING: + from uipath.agent.models.agent import ( + AgentIntegrationToolParameter, + AgentIntegrationToolResourceConfig, + ) + from uipath.platform.connections import ActivityMetadata + + from .runtime.mocking import MockSpec + +logger = logging.getLogger(__name__) + +__all__ = [ + "UiPathIntegrationTool", + "integration_tool_from_resource", + "uipath_integration_tool", +] + +#: Default host-enforced timeout of an Integration Service call. +DEFAULT_TIMEOUT_SEC = 300.0 +#: Methods an IS activity can have (``GETBYID`` is sent as ``GET``). +METHODS = frozenset({"GET", "GETBYID", "POST", "PUT", "PATCH", "DELETE"}) +_BODY_METHODS = frozenset({"POST", "PUT", "PATCH"}) +_LOCATIONS = ("query", "path", "header", "body", "multipart", "file") +_TEMPLATE_RE = re.compile(r"^\{\{(.+?)\}\}$") +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") +#: The output schema is added to the description only up to this size. +_OUTPUT_SCHEMA_DESCRIPTION_MAX = 4000 + + +# -------------------------------------------------------------------------- +# the tool +# -------------------------------------------------------------------------- +@dataclass +class UiPathIntegrationTool: + """An immediate tool that runs one Integration Service activity. + + Prefer :func:`uipath_integration_tool` or + :func:`integration_tool_from_resource` over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + connection_id: Connection the activity runs on. It is passed to the + SDK as is, so a resource overwrite can rebind it. + activity_metadata: Path, method, content type and parameter locations. + input_json_schema: Schema shown to the model (static fields removed). + input_model: Pydantic model the arguments are validated with, if any. + static_args: Fixed values by IS parameter name (``a.b``, ``x[*].y``), + merged over the model's arguments at call time. + argument_args: ``{{x}}`` parameters: IS name → path in the agent input. + output_json_schema: Output schema (description and eval mocks only). + connector_key: Connector key (tracing only), e.g. ``uipath-uipath-airdk``. + connection_name: Connection name (tracing only). + object_name: IS object name (tracing only), e.g. ``v2::webSearch``. + display_name: Name of the ``integrationTool`` span; defaults to ``name``. + timeout_sec: Host-enforced timeout of one call, in seconds. None + means the default (300); there is no way to turn it off. + kind: Span ``toolType`` (``Integration``). + example_calls: Example calls for eval mocking (``mockable``). + mock_name: Name the activity is simulated by in evals; None means + the display name (the resource name, e.g. ``Get User (v2)``). + """ + + #: The activity is mocked inside ``call`` (under its span), not by the host. + mocks_in_call: ClassVar[bool] = True + + name: str + description: str + connection_id: str + activity_metadata: ActivityMetadata + input_json_schema: dict[str, Any] = field( + default_factory=lambda: dict(_PERMISSIVE_SCHEMA) + ) + input_model: type[BaseModel] | None = None + static_args: dict[str, Any] = field(default_factory=dict) + argument_args: dict[str, str] = field(default_factory=dict) + output_json_schema: dict[str, Any] | None = None + connector_key: str | None = None + connection_name: str | None = None + object_name: str | None = None + display_name: str | None = None + timeout_sec: float | None = DEFAULT_TIMEOUT_SEC + kind: str = "integration" + example_calls: list[Any] | None = None + mock_name: str | None = None + + def __post_init__(self) -> None: + self._invoker: Any = None + self._validation_model: type[BaseModel] | None = None + self.timeout_sec = _check_timeout(self.name, self.timeout_sec) + + # --- UiPathTool ------------------------------------------------------- + + @property + def input_schema(self) -> dict[str, Any]: + return self.input_json_schema + + @property + def span_attributes(self) -> dict[str, Any]: + attrs = { + "connector_key": self.connector_key, + "connector_name": self.connection_name, + "connection_id": self.connection_id, + "object_name": self.object_name, + "tool.display_name": self.display_name, + } + return {k: v for k, v in attrs.items() if v is not None} + + def routed_names(self) -> set[str]: + """Top-level input keys the SDK sends somewhere (others it drops).""" + info = self.activity_metadata.parameter_location_info + return { + *info.query_params, + *info.path_params, + *info.header_params, + *info.multipart_params, + *info.body_fields, + } + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + if self.input_model is not None: + # Static values are fixed and hidden from the model: pre-fill them + # (at any depth) so a model that requires them validates. They + # are set again over the arguments at call time. + data = copy.deepcopy(args) + for name, value in self.static_args.items(): + _set_path(data, name, copy.deepcopy(value)) + try: + # by_alias: the advertised schema and the IS names use aliases. + out = ( + self._model_for_validation() + .model_validate(data) + .model_dump(mode="json", exclude_none=True, by_alias=True) + ) + except ValidationError as e: + raise ValueError(str(e)) from e + else: + _validate_against_schema(self.input_json_schema, args) + out = dict(args) + routed = self.routed_names() + dropped = [k for k in out if k not in routed] + if dropped: + logger.warning( + "Integration tool %s: argument(s) %s have no parameter location " + "and are not sent.", + self.name, + dropped, + ) + out = {k: v for k, v in out.items() if k in routed} + return out + + def _model_for_validation(self) -> type[BaseModel]: + """``input_model`` with top-level ``{{x}}`` fields made optional. + + Those are filled from the agent input at call time, so the model may + leave them out. + """ + model = self.input_model + assert model is not None + if self._validation_model is None: + targets = {n for n in self.argument_args if extract_top_level_field(n) == n} + overrides: dict[str, Any] = {} + for fname, info in model.model_fields.items(): + if (info.alias or fname) in targets and info.is_required(): + overrides[fname] = ( + Optional[info.annotation], # noqa: UP045 - runtime type + Field(None, alias=info.alias, description=info.description), + ) + self._validation_model = ( + create_model(model.__name__, __base__=model, **overrides) + if overrides + else model + ) + return self._validation_model + + async def call(self, args: dict[str, Any], ctx: ToolContext) -> str: + """Run the activity; return its JSON result as text. + + Raises: + UiPathToolError: The service refused the call, failed or timed + out; the message is shown to the model. + """ + from uipath.platform.errors import EnrichedException + + from .runtime.mocking import mark_simulated + from .runtime.telemetry import ( + end_integration_tool_span, + start_integration_tool_span, + ) + + payload = self.build_payload(args, ctx.agent_input) + display = self.display_name or self.name + span = start_integration_tool_span(display, arguments=payload) + from opentelemetry import trace + + try: + with trace.use_span(span, end_on_exit=False): + result = await self._invoke()(**payload) + except BaseException as e: + end_integration_tool_span(span, error=e) + if isinstance(e, EnrichedException): + raise UiPathToolError(self._enriched_message(e)) from e + if _is_httpx_timeout(e): + logger.warning( + "Integration service call for tool %s timed out.", self.name + ) + raise UiPathToolError( + f"Integration service call for tool '{self.name}' timed out. " + "You may retry." + ) from e + if type(e) is ValueError: + raise UiPathToolError( + f"Tool '{self.name}' is misconfigured: {e}" + ) from e + raise + mark_simulated(span) + end_integration_tool_span(span, result=result) + return json.dumps(result, ensure_ascii=False, default=str) + + # --- helpers ---------------------------------------------------------- + + def build_payload( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with static and ``{{x}}`` values merged in.""" + payload = copy.deepcopy(args) + for name, value in self.static_args.items(): + _set_path(payload, name, copy.deepcopy(value)) + for name, path in self.argument_args.items(): + found, value = _get_path(agent_input, path) + if not found: + logger.debug( + "Integration tool %s: {{%s}} is not in the agent input; " + "%s is not set.", + self.name, + path, + name, + ) + continue + _set_path(payload, name, copy.deepcopy(value)) + # Serialization sanitising (langchain's sanitize_dict_for_serialization). + return json.loads(json.dumps(payload, default=str)) + + def _invoke(self) -> Any: + if self._invoker is not None: + return self._invoker + + async def invoke_activity(**activity_input: Any) -> Any: + # Looked up at call time (tests patch it). + client = _tools._uipath_client() + return await client.connections.invoke_activity_async( + activity_metadata=self.activity_metadata, + connection_id=self.connection_id, + activity_input=activity_input, + ) + + from .runtime.mocking import wrap + + self._invoker = wrap(self.mock_spec, invoke_activity) + return self._invoker + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's: by the resource name, with its output schema.""" + from .runtime.mocking import ANY_OBJECT_SCHEMA, MockSpec, mock_name_of + + return MockSpec( + name=mock_name_of(self), + description=self.description, + input_schema=self.input_json_schema, + output_schema=self.output_json_schema or dict(ANY_OBJECT_SCHEMA), + example_calls=list(self.example_calls or []), + ) + + def _enriched_message(self, e: Any) -> str: + """langchain's ``_INTEGRATION_ERRORS`` mapping, as model-facing text.""" + info = getattr(e, "error_info", None) + code = getattr(info, "error_code", None) if info is not None else None + message = (getattr(info, "message", None) if info is not None else None) or str( + e + ).strip() + status = getattr(e, "status_code", None) + if status == 400 or (status == 422 and code == "INVALID_DATA"): + logger.warning( + "Integration service rejected the arguments of tool %s (HTTP %s): %s", + self.name, + status, + message, + ) + return ( + f"Integration service returned an error for tool '{self.name}': " + f"{message}" + ) + logger.error( + "Integration service call for tool %s failed (HTTP %s): %s", + self.name, + status, + message, + ) + return ( + f"Integration service call for tool '{self.name}' failed " + f"(HTTP {status}): {message}" + ) + + +def _is_httpx_timeout(e: BaseException) -> bool: + try: + import httpx + except ImportError: # pragma: no cover + return False + return isinstance(e, httpx.TimeoutException) + + +# -------------------------------------------------------------------------- +# IS parameter names +# -------------------------------------------------------------------------- +def extract_top_level_field(name: str) -> str: + """Top-level field of an IS parameter name (``a.b`` → ``a``, ``x[*]`` → ``x``).""" + return name.split(".")[0].split("[")[0] + + +# -------------------------------------------------------------------------- +# ActivityMetadata (port of langchain's convert_to_activity_metadata) +# -------------------------------------------------------------------------- +def _to_activity_metadata( + tool_path: str, + method: str, + body_structure: Mapping[str, Any] | None, + located: Sequence[tuple[str, str]], +) -> ActivityMetadata: + """``ActivityMetadata`` from ``(IS name, fieldLocation)`` pairs. + + GETBYID is sent as GET. ``bodyStructure.contentType == "multipart"`` means + ``multipart/form-data`` (with ``jsonBodySection``), anything else JSON. + The multipart envelope marker (a multipart/file parameter named like the + JSON body section) is skipped. query/path/header names map 1:1, + multipart/file go to multipart, body and unknown locations to the body by + top-level field. + """ + from uipath.platform.connections import ( + ActivityMetadata, + ActivityParameterLocationInfo, + ) + + http_method = method.upper() + if http_method == "GETBYID": + http_method = "GET" + content_type = "application/json" + json_body_section = None + if body_structure is not None: + if body_structure.get("contentType", "json") == "multipart": + content_type = "multipart/form-data" + json_body_section = body_structure.get("jsonBodySection") + + info = ActivityParameterLocationInfo() + targets = { + "query": info.query_params, + "path": info.path_params, + "header": info.header_params, + "multipart": info.multipart_params, + "file": info.multipart_params, + } + for name, location in located: + if location in ("multipart", "file") and name == json_body_section: + continue + target = targets.get(location) + key = name + if target is None: + target, key = info.body_fields, extract_top_level_field(name) + if key not in target: + target.append(key) + return ActivityMetadata( + object_path=tool_path, + method_name=http_method, + content_type=content_type, + parameter_location_info=info, + json_body_section=json_body_section, + ) + + +# -------------------------------------------------------------------------- +# schema editing +# -------------------------------------------------------------------------- +def fold_enums(schema: dict[str, Any], param_names: Sequence[str]) -> dict[str, Any]: + """Fold each parameter's allowed values into its description; drop ``enum``. + + Port of langchain's ``strip_enums_from_schema`` / ``handle_enum``: the + values come from ``enum`` or ``oneOf[].const``; ``{{…}}`` placeholders are + not allowed values. Returns a modified deep copy. + """ + schema = copy.deepcopy(schema) + for name in param_names: + node = _navigate(schema, _segments(name)) + if node is None: + continue + values = node.get("enum") + if not values and isinstance(node.get("oneOf"), list): + values = [ + b["const"] + for b in node["oneOf"] + if isinstance(b, dict) and "const" in b + ] + if values and not any( + isinstance(v, str) and _TEMPLATE_RE.match(v) for v in values + ): + hint = f"Allowed value(s): {', '.join(str(v) for v in values)}." + description = node.get("description") + node["description"] = f"{description} ({hint})" if description else hint + node.pop("enum", None) + return schema + + +def remove_asterisk(schema: dict[str, Any]) -> dict[str, Any]: + """Remove the ``[*]`` IS puts in property names, definitions and ``$ref``. + + Port of langchain's ``remove_asterisk_from_properties`` (an IS bug + workaround). Returns a modified deep copy. + """ + schema = copy.deepcopy(schema) + + def fix(node: Any) -> None: + if not isinstance(node, dict): + return + if isinstance(node.get("$ref"), str): + node["$ref"] = node["$ref"].replace("[*]", "") + if node.get("type") == "object" and isinstance(node.get("properties"), dict): + fixed = {} + for key, value in node["properties"].items(): + fixed[key.replace("[*]", "")] = value + fix(value) + node["properties"] = fixed + if node.get("type") == "array": + fix(node.get("items")) + + for key in ("$defs", "definitions"): + if isinstance(schema.get(key), dict): + fixed = {} + for name, value in schema[key].items(): + fixed[name.replace("[*]", "")] = value + fix(value) + schema[key] = fixed + fix(schema) + return schema + + +def _check_timeout(name: str, timeout_sec: float | None) -> float: + """None means the default; a non-positive timeout is refused.""" + if timeout_sec is None: + return DEFAULT_TIMEOUT_SEC + if timeout_sec <= 0: + raise ValueError( + f"Integration tool '{name}': timeout_sec must be positive " + f"(got {timeout_sec!r}); None means the {DEFAULT_TIMEOUT_SEC:g}s default." + ) + return float(timeout_sec) + + +def _lowcode_tool_name(name: str) -> str: + """langchain's ``sanitize_tool_name``: low-code agents name tools this way. + + Whitespace runs become one ``_``, other disallowed characters are dropped, + then cut to 64 (``Get User (v2)`` → ``Get_User_v2``). + """ + cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", "_".join(name.split()))[:64] + return cleaned or sanitize_tool_name(name) + + +def _description( + description: str | None, + display: str, + method: str, + tool_path: str, + output_schema: dict[str, Any] | None, +) -> str: + text = description or ( + f"Runs the Integration Service activity '{display}' ({method} {tool_path})." + ) + if output_schema and output_schema.get("properties"): + compact = json.dumps( + {"type": "object", "properties": output_schema["properties"]}, + separators=(",", ":"), + ) + if len(compact) <= _OUTPUT_SCHEMA_DESCRIPTION_MAX: + text += " Returns JSON matching: " + compact + return text + + +# -------------------------------------------------------------------------- +# parameters +# -------------------------------------------------------------------------- +def _parameter(raw: Any) -> AgentIntegrationToolParameter: + from uipath.agent.models.agent import AgentIntegrationToolParameter + + if isinstance(raw, AgentIntegrationToolParameter): + return raw + if not isinstance(raw, Mapping): + raise ValueError(f"parameter must be a mapping, got {type(raw).__name__}") + data = dict(raw) + data.setdefault("type", "string") + try: + return AgentIntegrationToolParameter.model_validate(data) + except ValidationError as e: + raise ValueError(f"invalid parameter {data.get('name')!r}: {e}") from e + + +def _fixed_values( + parameters: Sequence[AgentIntegrationToolParameter], +) -> tuple[dict[str, Any], dict[str, str]]: + """``(static_args, argument_args)`` from ``static`` / ``argument`` parameters.""" + static: dict[str, Any] = {} + arguments: dict[str, str] = {} + for p in parameters: + if p.field_variant == "static": + static[p.name] = p.value + elif p.field_variant == "argument": + text = str(p.value) if p.value is not None else "" + match = _TEMPLATE_RE.match(text) + if not match: + raise ValueError( + f"Argument parameter '{p.name}' has a malformed template: " + f"{p.value!r}. Expected '{{{{argName}}}}'." + ) + arguments[p.name] = match.group(1).strip() + return static, arguments + + +# -------------------------------------------------------------------------- +# factories +# -------------------------------------------------------------------------- +def uipath_integration_tool( + name: str, + *, + connection_id: str, + tool_path: str, + method: str = "POST", + description: str | None = None, + input_schema: type[BaseModel] | dict[str, Any] | None = None, + output_schema: type[BaseModel] | dict[str, Any] | None = None, + parameters: Sequence[AgentIntegrationToolParameter | Mapping[str, Any]] + | None = None, + body_structure: Mapping[str, Any] | None = None, + static_args: Mapping[str, Any] | None = None, + connector_key: str | None = None, + connection_name: str | None = None, + object_name: str | None = None, + tool_name: str | None = None, + timeout_sec: float | None = DEFAULT_TIMEOUT_SEC, +) -> UiPathIntegrationTool: + """Declare an Integration Service activity as an immediate tool. + + Args: + name: Tool name (sanitized into the model-facing name) and the + display name of its ``integrationTool`` span. + connection_id: Id of the IS connection. It is also the key of the + ``connection`` binding in ``bindings.json``. + tool_path: Activity path, e.g. ``/v2/webSearch`` or + ``/UsersByEmail/{usersByEmailId}`` (``{x}`` becomes a path + parameter). + method: ``GET``, ``GETBYID``, ``POST``, ``PUT``, ``PATCH`` or ``DELETE``. + description: What the tool does, for the model. + input_schema: Pydantic model or JSON schema of the arguments the + model fills in. Its top-level properties are sent in the body + (POST/PUT/PATCH) or the query (GET/GETBYID/DELETE) unless + ``parameters`` says otherwise. + output_schema: Pydantic model or JSON schema of the result, added to + the description. + parameters: Low-code style parameters (``name``, ``fieldLocation``: + query/path/header/body/multipart/file, optional ``fieldVariant`` + static/argument/dynamic and ``value``). ``static`` ones are fixed + values hidden from the model; ``argument`` ones (``{{x}}``) come + from the agent input when it has the value (the model sees them + as optional fields and fills them otherwise). + body_structure: ``{"contentType": "json"|"multipart", "jsonBodySection": ...}``. + static_args: Fixed values by IS name (``a.b``, ``x[*].y`` work too), + hidden from the model and merged over its arguments. + connector_key: Connector key, e.g. ``uipath-uipath-airdk`` (tracing). + connection_name: Connection name (tracing). + object_name: IS object name, e.g. ``v2::webSearch`` (tracing). + tool_name: Model-facing name; defaults to the sanitized ``name``. + timeout_sec: Host-enforced timeout of one call, in seconds. None means + the 300 s default; it cannot be turned off. + + Raises: + ValueError: An empty ``tool_path`` or ``connection_id``, an unknown + method, an invalid parameter, or a name in two locations. + """ + if not tool_path or not tool_path.strip(): + raise ValueError(f"Integration tool '{name}' needs a non-empty tool_path.") + if not connection_id or not str(connection_id).strip(): + raise ValueError(f"Integration tool '{name}' needs a connection_id.") + upper = method.upper() + if upper not in METHODS: + raise ValueError( + f"Integration tool '{name}': unsupported method {method!r} " + f"(expected one of {', '.join(sorted(METHODS))})." + ) + params = [_parameter(p) for p in parameters or []] + static, arguments = _fixed_values(params) + static.update(dict(static_args or {})) + + input_model = ( + input_schema + if isinstance(input_schema, type) and issubclass(input_schema, BaseModel) + else None + ) + if input_schema is not None: + schema = _schema_from(input_schema) + elif params: + schema = _schema_from_parameters(params) + else: + schema = dict(_PERMISSIVE_SCHEMA) + + # Locations: explicit parameters, then {x} in the path, then the rest by method. + locations: dict[str, str] = {} + located: list[tuple[str, str]] = [] + + def locate(param_name: str, location: str, *, explicit: bool) -> None: + location = location if location in _LOCATIONS else "body" + key = extract_top_level_field(param_name) if location == "body" else param_name + group = "multipart" if location == "file" else location + known = locations.get(key) + if known is not None and known != group: + if not explicit: + return + raise ValueError( + f"Integration tool '{name}': parameter {key!r} is in two locations " + f"({known} and {group})." + ) + locations[key] = group + located.append((param_name, location)) + + for p in params: + locate(p.name, p.field_location, explicit=True) + for placeholder in _PLACEHOLDER_RE.findall(tool_path): + if placeholder not in locations: + locate(placeholder, "path", explicit=False) + default = "body" if upper in _BODY_METHODS else "query" + for prop in (schema.get("properties") or {}).keys(): + if extract_top_level_field(prop) not in locations: + locate(prop, default, explicit=False) + for key in static: + if extract_top_level_field(key) not in locations: + locate(key, default, explicit=False) + + output_json = _schema_from(output_schema) if output_schema is not None else None + return UiPathIntegrationTool( + name=sanitize_tool_name(tool_name or name), + description=_description(description, name, upper, tool_path, output_json), + connection_id=str(connection_id), + activity_metadata=_to_activity_metadata( + tool_path, upper, body_structure, located + ), + input_json_schema=_note_arguments( + _hide_static(schema, list(static)), arguments + ), + input_model=input_model, + static_args=static, + argument_args=arguments, + output_json_schema=output_json, + connector_key=connector_key, + connection_name=connection_name, + object_name=object_name, + display_name=name, + timeout_sec=timeout_sec, + ) + + +def _schema_from_parameters( + params: Sequence[AgentIntegrationToolParameter], +) -> dict[str, Any]: + """An input schema from the top-level parameters the model may fill.""" + props: dict[str, Any] = {} + required: list[str] = [] + for p in params: + if p.field_variant == "static": + continue + if extract_top_level_field(p.name) != p.name: + continue + spec: dict[str, Any] = {"type": p.type or "string"} + if p.description: + spec["description"] = p.description + props[p.name] = spec + # ``{{x}}`` fields stay visible (the model fills them when the agent + # input has no value) but are never required. + if p.required and p.field_variant != "argument": + required.append(p.name) + schema: dict[str, Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + + +def integration_tool_from_resource( + resource: AgentIntegrationToolResourceConfig | Mapping[str, Any], + *, + tool_name: str | None = None, + timeout_sec: float | None = None, +) -> UiPathIntegrationTool: + """An immediate tool from a low-code ``Integration`` tool resource. + + Args: + resource: An ``AgentIntegrationToolResourceConfig``, or the resource + dict as found in a low-code ``agent.json`` (``resources[]`` entry + with ``"type": "Integration"``). + tool_name: Model-facing name; defaults to the resource name sanitized + the way low-code agents do (``Get User (v2)`` → ``Get_User_v2``). + timeout_sec: Host-enforced timeout; defaults to the resource's + ``settings.timeout``, else 300 seconds. + + Raises: + ValueError: The resource is disabled, has no ``toolPath`` or no + ``connection.id``, or does not validate. + """ + from uipath.agent.models.agent import AgentIntegrationToolResourceConfig + + if isinstance(resource, Mapping): + data = dict(resource) + if str(data.get("type", "")).lower() == "integration": + data["type"] = "Integration" + try: + resource = AgentIntegrationToolResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid Integration tool resource: {e}") from e + if resource.is_enabled is False: + raise ValueError( + f"Integration tool resource '{resource.name}' is disabled; filter it out " + "before building tools." + ) + props = resource.properties + if not props.tool_path or not props.tool_path.strip(): + raise ValueError( + f"Integration tool resource '{resource.name}' has an empty 'toolPath'." + ) + connection = props.connection + if not getattr(connection, "id", None): + raise ValueError( + f"Integration tool resource '{resource.name}' has no 'connection.id'." + ) + method = props.method.upper() + if method not in METHODS: + raise ValueError( + f"Integration tool resource '{resource.name}': unsupported method " + f"{props.method!r}." + ) + static, arguments = _fixed_values(props.parameters) + schema = fold_enums(resource.input_schema, [p.name for p in props.parameters]) + output = remove_asterisk(resource.output_schema) if resource.output_schema else None + connector = connection.connector if isinstance(connection.connector, dict) else {} + if timeout_sec is None: + settings_timeout = resource.settings.timeout if resource.settings else None + timeout_sec = ( + float(settings_timeout) if settings_timeout else DEFAULT_TIMEOUT_SEC + ) + description = resource.description or props.tool_description + return UiPathIntegrationTool( + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=_description( + description, resource.name, method, props.tool_path, output + ), + connection_id=str(connection.id), + activity_metadata=_to_activity_metadata( + props.tool_path, + method, + props.body_structure, + [(p.name, p.field_location) for p in props.parameters], + ), + input_json_schema=_note_arguments( + _hide_static(schema, list(static)), arguments + ), + static_args=static, + argument_args=arguments, + output_json_schema=output, + connector_key=connector.get("key"), + connection_name=connection.name, + object_name=props.object_name, + display_name=resource.name, + timeout_sec=timeout_sec, + example_calls=list(props.example_calls) if props.example_calls else None, + mock_name=resource.name, + ) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/ixp_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/ixp_tools.py new file mode 100644 index 00000000..92c9d22e --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/ixp_tools.py @@ -0,0 +1,893 @@ +"""IXP extraction and its Document Validation escalation, as in low-code agents. + +Two durable tools that work as a pair: + +* :class:`UiPathIxpExtractionTool` (agent.json ``tool`` of ``type: "Ixp"``): + the model passes a document (a JobAttachment); the tool downloads it, + starts an IXP extraction on the project's tagged model version and + suspends the run. On resume the model gets the extracted fields (the + ``dataProjection``), and the full extraction response is kept for the + validation tool. +* :class:`UiPathDocumentValidationTool` (agent.json ``escalation`` with + ``escalationType: 1``): takes no arguments; it creates an Action Center + Validation Station task for the last extraction of its IXP tool and + suspends. On resume the model gets the validated fields, ``{"data": ...}``; + a document marked as an exception (or a deleted task) ends the run with + ``TERMINATION_ESCALATION_REJECTED``, as in langchain. + +As in langchain, a call without a document, a download or start error and a +failed extraction or validation operation fail the run. +""" + +from __future__ import annotations + +import copy +import json +import logging +import os +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Mapping + +from pydantic import ValidationError +from uipath.runtime.errors import UiPathErrorCategory + +from . import tools as _tools +from .integration_tools import _get_path, _lowcode_tool_name +from .runtime.errors import ( + UiPathCodexErrorCode, + UiPathCodexRuntimeError, + UiPathRunTerminatedError, +) +from .tools import ( + DurableToolContext, + UiPathDurableToolError, + UiPathDurableToolPending, + _validate_against_schema, + sanitize_tool_name, +) + +if TYPE_CHECKING: + from .runtime.mocking import MockSpan, MockSpec + +logger = logging.getLogger(__name__) + +__all__ = [ + "UiPathDocumentValidationTool", + "UiPathIxpExtractionTool", + "ixp_extraction_tool_from_resource", + "uipath_document_validation_tool", + "uipath_ixp_extraction_tool", + "vs_escalation_tool_from_resource", +] + +DEFAULT_EXTRACTION_DESCRIPTION = ( + "Extracts structured data from a document with UiPath IXP." +) +DEFAULT_VALIDATION_DESCRIPTION = ( + "Sends the last extracted document to a person for validation in Action " + "Center and returns the validated fields." +) +#: langchain's default Validation Station task title. +DEFAULT_VALIDATION_TITLE = "VS Escalation Task" +_VS_PENDING = frozenset({"pending", "unassigned"}) + + +def ixp_extraction_input_schema() -> dict[str, Any]: + """langchain's (and Agent Builder's) one-document argument schema.""" + from uipath.platform.attachments import Attachment + + attachment = Attachment.model_json_schema(by_alias=True) + attachment["required"] = ["ID"] + attachment["x-uipath-resource-kind"] = "JobAttachment" + attachment.pop("title", None) + return { + "type": "object", + "properties": { + "attachment": { + "description": "File to extract data from.", + "$ref": "#/definitions/job-attachment", + } + }, + "required": ["attachment"], + "definitions": {"job-attachment": attachment}, + } + + +def _fatal(title: str, detail: str) -> UiPathCodexRuntimeError: + return UiPathCodexRuntimeError( + UiPathCodexErrorCode.IXP_FAILED, title, detail, UiPathErrorCategory.USER + ) + + +def _value(enum_or_str: Any) -> Any: + return getattr(enum_or_str, "value", enum_or_str) + + +# -------------------------------------------------------------------------- +# extraction +# -------------------------------------------------------------------------- +@dataclass +class UiPathIxpExtractionTool: + """A durable tool that extracts a document's fields with an IXP project. + + Prefer :func:`uipath_ixp_extraction_tool` or + :func:`ixp_extraction_tool_from_resource` over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + project_name: The IXP project. + version_tag: The tag of the project's published model version. + input_json_schema: Arguments; the first attachment field is the + document. + display_name: Name of the ``ixpTool`` span and the key the + validation tool finds the result under (the resource name). + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span (low-code's). + mock_name: Name the tool is simulated by in evals; None means the + display name (the resource name). + example_calls: Example calls for the eval LLM mocker. + """ + + name: str + description: str + project_name: str + version_tag: str + input_json_schema: dict[str, Any] = field( + default_factory=ixp_extraction_input_schema + ) + display_name: str | None = None + kind: str = "ixp" + tool_type: str = "IxpExtraction" + mock_name: str | None = None + example_calls: list[Any] = field(default_factory=list) + + def __post_init__(self) -> None: + from .attachments import attachment_paths + + label = f"IXP tool '{self.name}'" + if not self.project_name: + raise ValueError(f"{label}: project_name is required.") + if not self.version_tag: + raise ValueError(f"{label}: version_tag (the model's tag) is required.") + if not attachment_paths(self.input_json_schema): + self.input_json_schema = ixp_extraction_input_schema() + + @property + def result_key(self) -> str: + return self.display_name or self.name + + # --- UiPathDurableTool ------------------------------------------------ + + @property + def input_schema(self) -> dict[str, Any]: + return self.input_json_schema + + @property + def span_attributes(self) -> dict[str, Any]: + return {"tool.display_name": self.result_key} + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + _validate_against_schema(self.input_schema, args) + return json.loads(json.dumps(args, default=str)) + + def started_output(self, value: Any) -> dict[str, Any]: + extraction = getattr(value, "extraction", None) + return { + "status": "started", + "extraction_id": getattr(extraction, "operation_id", None), + "document_id": getattr(extraction, "document_id", None), + } + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Download the document, start the extraction; ``WaitDocumentExtraction``. + + Raises: + UiPathCodexRuntimeError: No document, or it could not be downloaded + or extracted (the run fails, as in langchain). + """ + from opentelemetry import trace + from uipath.platform.common import WaitDocumentExtraction + + from .attachments import ( + dump_attachment, + not_found_message, + span_attachments, + to_attachment, + values_at, + ) + from .runtime.telemetry import fail_span, hold_span, start_ixp_tool_span + from .runtime.tool_host import input_paths + + attachment = None + for value in values_at(args, input_paths(self)): + attachment = to_attachment(value) + if attachment is None and isinstance(value, dict) and ctx.attachments: + raw_id = value.get("ID", value.get("id")) + attachment = ctx.attachments.get(str(raw_id)) if raw_id else None + if attachment is None and raw_id: + raise _fatal( + "Unknown job attachment", not_found_message(str(raw_id)) + ) + if attachment is not None: + break + if attachment is None: + raise _fatal( + "Missing job attachment", + f"Tool '{self.result_key}' was called without a job attachment to " + "extract data from.", + ) + + span = start_ixp_tool_span( + self.result_key, + arguments={**args, "attachment": dump_attachment(attachment)}, + project_name=self.project_name, + version_tag=self.version_tag, + attachments=span_attachments([attachment], "in"), + ) + try: + with trace.use_span(span, end_on_exit=False): + started = await _start_extraction( + attachment, self.project_name, self.version_tag + ) + except BaseException as e: + fail_span(span, e) + span.end() + raise + span.set_attribute("extractionId", started.operation_id) + span.set_attribute("documentId", started.document_id) + hold_span(span, output_attribute="result") + if ctx.results is not None: + ctx.results.mark_pending(self.result_key) + logger.info( + "IXP tool %s started extraction %s of %s (call %s)", + self.name, + started.operation_id, + attachment.full_name, + ctx.call_id, + ) + return WaitDocumentExtraction(extraction=started) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """The extracted fields (``dataProjection``) as JSON.""" + response = _extraction_response(resume_value) + projection = response.model_dump(by_alias=True, mode="json").get( + "dataProjection" + ) + return json.dumps( + projection if projection is not None else [], ensure_ascii=False + ) + + def stored_result(self, args: dict[str, Any], resume_value: Any) -> tuple[str, Any]: + """The full extraction response, for the Document Validation tool.""" + response = _extraction_response(resume_value) + return self.result_key, response.model_dump(by_alias=True, mode="json") + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's extraction tool: an ``ExtractionResponseIXP``.""" + import uipath.platform.common # noqa: F401 - before documents (import cycle) + from uipath.platform.documents import ExtractionResponseIXP + + from .runtime.mocking import MockSpec + + return MockSpec( + name=self.mock_name or self.result_key, + description=self.description, + input_schema=self.input_schema, + output_schema=ExtractionResponseIXP.model_json_schema(by_alias=True), + example_calls=list(self.example_calls), + ) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan: + """The ``ixpTool`` child, without an extraction or document id.""" + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_ixp_tool_span + + span = start_ixp_tool_span( + self.result_key, + arguments=args, + project_name=self.project_name, + version_tag=self.version_tag, + ) + return MockSpan(span, "result") + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """Keep the simulated extraction for the validation tool; its ``dataProjection``. + + Raises: + UiPathDurableToolError: The value is not an extraction response. + """ + key, stored = self.stored_result(args, value) + results = getattr(ctx, "results", None) + if results is not None: + results.put(key, stored) + return await self.finish(args, value) + + +def _extraction_response(value: Any) -> Any: + from uipath.platform.documents import ExtractionResponseIXP + + if isinstance(value, ExtractionResponseIXP): + return value + try: + return ExtractionResponseIXP.model_validate(value) + except ValidationError as e: + raise UiPathDurableToolError( + f"Unexpected IXP extraction result: {str(value)[:200]}" + ) from e + + +async def _start_extraction(attachment: Any, project_name: str, tag: str) -> Any: + """Download ``attachment`` to a temporary folder and start its extraction.""" + import uipath.platform.common # noqa: F401 - before documents (import cycle) + from uipath.platform.errors import EnrichedException + + client = _tools._uipath_client() + name = Path(str(attachment.full_name).replace("\\", "/")).name or "document" + with tempfile.TemporaryDirectory(prefix="codex-ixp-") as folder: + path = os.path.join(folder, name) + try: + await client.attachments.download_async( + key=attachment.id, destination_path=path + ) + except Exception as e: + raise _fatal( + "Failed to download job attachment", + f"Attachment '{attachment.full_name}' ({attachment.id}) could not " + f"be downloaded: {e}", + ) from e + try: + return await client.documents.start_ixp_extraction_async( + project_name=project_name, tag=tag, file_path=path + ) + except (EnrichedException, ValueError, Exception) as e: + raise _fatal( + "IXP extraction could not start", + f"Extraction of '{attachment.full_name}' with IXP project " + f"'{project_name}' (tag '{tag}') could not start: {e}", + ) from e + + +# -------------------------------------------------------------------------- +# document validation +# -------------------------------------------------------------------------- +@dataclass +class UiPathDocumentValidationTool: + """A durable tool that sends the last extraction to Validation Station. + + Prefer :func:`uipath_document_validation_tool` or + :func:`vs_escalation_tool_from_resource` over constructing this directly. + + Attributes: + name: Model-facing tool name. + description: Description shown to the model. + extraction_tool: The IXP tool whose last result is validated (its + display name: the agent.json ``ixpToolId``). + storage_bucket_name: Orchestrator storage bucket the document is + stored in for the task. + storage_bucket_directory_path: Directory inside the bucket. + action_folder: Orchestrator folder of the bucket (and the task); None + means the job's (``UIPATH_FOLDER_PATH``). DU looks the bucket up + in this folder only. + action_catalog: Action catalog of the task. + priority: ``Low`` / ``Medium`` / ``High`` / ``Critical``. + title: Fixed task title. + title_from_input: Path of the title in the agent input. + title_tokens: Text tokens of the title (agent.json text builder). + display_name: Name of the ``vsEscalationTool`` span. + kind: ``tool.kind`` of the ``Tool call`` span. + tool_type: ``toolType`` of the ``Tool call`` span (low-code's). + mock_name: Name the tool is simulated by in evals; None means the + display name (the resource name). + """ + + name: str + description: str + extraction_tool: str + storage_bucket_name: str | None = None + storage_bucket_directory_path: str | None = None + action_folder: str | None = None + action_catalog: str | None = None + priority: str | None = None + title: str | None = None + title_from_input: str | None = None + title_tokens: list[Any] | None = None + display_name: str | None = None + kind: str = "escalation" + tool_type: str = "Escalation" + mock_name: str | None = None + + def __post_init__(self) -> None: + if not self.extraction_tool: + raise ValueError( + f"Document validation tool '{self.name}': extraction_tool is required." + ) + + # --- UiPathDurableTool ------------------------------------------------ + + @property + def input_schema(self) -> dict[str, Any]: + return {"type": "object", "properties": {}} + + @property + def span_attributes(self) -> dict[str, Any]: + return {"tool.display_name": self.display_name or self.name} + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + return {} + + def started_output(self, value: Any) -> dict[str, Any]: + validation = getattr(value, "extraction_validation", None) + return { + "status": "started", + "operation_id": getattr(validation, "operation_id", None), + "task_url": getattr(value, "task_url", None), + } + + def resolve_title(self, agent_input: dict[str, Any] | None) -> str: + if self.title_from_input: + found, value = _get_path(agent_input, self.title_from_input) + if found and value not in (None, ""): + return str(value) + if self.title_tokens is not None: + from uipath.agent.utils.text_tokens import build_string_from_tokens + + built = build_string_from_tokens(self.title_tokens, agent_input or {}) + if built: + return built + return self.title or DEFAULT_VALIDATION_TITLE + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Create the Validation Station task; ``WaitDocumentExtractionValidation``. + + Raises: + UiPathDurableToolError: The IXP tool has no result yet (the model + must call it first and wait for it). + UiPathCodexRuntimeError: The task could not be created (the run + fails, as in langchain). + """ + import uipath.platform.common # noqa: F401 - before documents (import cycle) + from opentelemetry import trace + from uipath.platform.common import WaitDocumentExtractionValidation + from uipath.platform.documents import ( + ActionPriority, + ExtractionResponseIXP, + StartExtractionValidationResponse, + ) + + from .runtime.telemetry import ( + fail_span, + hold_span, + start_vs_escalation_tool_span, + ) + + key = self.extraction_tool + saved = self._require_extraction(ctx.results) + extraction = ExtractionResponseIXP.model_validate(saved) + span = start_vs_escalation_tool_span( + self.display_name or self.name, + ixp_tool_id=key, + storage_bucket_name=self.storage_bucket_name, + ) + try: + with trace.use_span(span, end_on_exit=False): + client = _tools._uipath_client() + try: + action = await client.documents.create_validate_extraction_action_async( + extraction_response=extraction, + action_title=self.resolve_title(ctx.agent_input), + action_priority=ActionPriority.from_str(self.priority), + action_catalog=self.action_catalog, + # DU finds the bucket (and puts the task) only in + # this folder: default to the job's. + action_folder=self.action_folder + or os.environ.get("UIPATH_FOLDER_PATH") + or None, + storage_bucket_name=self.storage_bucket_name, + storage_bucket_directory_path=self.storage_bucket_directory_path, + ) + except Exception as e: + raise _fatal( + "Document validation task could not be created", + f"The Validation Station task for '{key}' could not be " + f"created: {e}", + ) from e + except BaseException as e: + fail_span(span, e) + span.end() + raise + task_url = (action.action_data or {}).get("taskUrl") + span.set_attribute("operationId", action.operation_id) + if task_url: + span.set_attribute("taskUrl", str(task_url)) + hold_span(span, output_attribute="result") + logger.info( + "Document validation tool %s created task %s (operation %s, call %s)", + self.name, + task_url, + action.operation_id, + ctx.call_id, + ) + return WaitDocumentExtractionValidation( + extraction_validation=StartExtractionValidationResponse.model_validate( + { + "operationId": action.operation_id, + "documentId": extraction.extraction_result.document_id, + "projectId": extraction.project_id, + "tag": extraction.tag, + } + ), + task_url=task_url, + ) + + def _require_extraction(self, results: Any) -> Any: + """The saved result of the extraction tool (real or simulated). + + Raises: + UiPathDurableToolError: The extraction has not returned yet, or + was never called. + """ + key = self.extraction_tool + if results is not None and results.is_pending(key): + raise UiPathDurableToolError( + f"The extraction of '{key}' has not finished yet. Wait for its " + f"result, then call '{self.name}'." + ) + saved = results.get(key) if results is not None else None + if not saved: + raise UiPathDurableToolError( + f"Extraction result not found for {key} ixp extraction tool. " + f"Call it first and wait for its result, then call '{self.name}'." + ) + return saved + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated as langchain's VS escalation: ``{"data": [field groups] | null}``.""" + import uipath.platform.common # noqa: F401 - before documents (import cycle) + from pydantic import create_model + from uipath.platform.documents import FieldGroupValueProjection + + from .runtime.mocking import MockSpec + + output = create_model( + "OutputSchema", + data=(list[FieldGroupValueProjection] | None, None), + ) + return MockSpec( + name=self.mock_name or self.display_name or self.name, + description=self.description, + input_schema=self.input_schema, + output_schema=output.model_json_schema(), + example_calls=[], + ) + + def mock_precheck(self, args: dict[str, Any], ctx: Any) -> None: + """A simulated validation still needs an extraction, as in langchain.""" + self._require_extraction(getattr(ctx, "results", None)) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan: + """The ``vsEscalationTool`` child, without a task.""" + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_vs_escalation_tool_span + + span = start_vs_escalation_tool_span( + self.display_name or self.name, + ixp_tool_id=self.extraction_tool, + storage_bucket_name=self.storage_bucket_name, + ) + return MockSpan(span, "result") + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """The simulated validated fields, ``{"data": ...}``.""" + data = value.get("data") if isinstance(value, dict) else None + return json.dumps({"data": data}, ensure_ascii=False, default=str) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """The validated fields, ``{"data": }`` (langchain's). + + Raises: + UiPathDurableToolPending: The task is still open. + UiPathRunTerminatedError: The document was marked as an exception, + or the task was deleted (the run ends). + """ + data = _validation_data(resume_value) + action_data = data.get("actionData") or {} + status = str(data.get("actionStatus") or action_data.get("status") or "") + if status.lower() in _VS_PENDING: + raise UiPathDurableToolPending(f"validation task is {status}") + if status.lower() == "deleted": + raise UiPathRunTerminatedError( + "VS Escalation Exception", + "Agent run ended because the document validation task was deleted.", + json.dumps({"status": "deleted"}), + ) + rejection = (action_data.get("data") or {}).get("documentRejectionDetails") + if rejection: + reason = rejection.get("reason") if isinstance(rejection, dict) else None + raise UiPathRunTerminatedError( + "VS Escalation Exception", + "Vs Escalation Exception: " + + (reason or "The validation was marked as exception."), + json.dumps({"status": "rejected", "reason": reason}), + ) + return json.dumps({"data": data.get("dataProjection")}, ensure_ascii=False) + + +def _validation_data(value: Any) -> dict[str, Any]: + from uipath.platform.documents import ValidateExtractionAction + + if isinstance(value, ValidateExtractionAction): + return value.model_dump(by_alias=True, mode="json") + if isinstance(value, dict): + try: + return ValidateExtractionAction.model_validate(value).model_dump( + by_alias=True, mode="json" + ) + except ValidationError: + if "actionData" in value or "dataProjection" in value: + return value # a raw --resume answer + raise UiPathDurableToolError( + f"Unexpected document validation result: {str(value)[:200]}" + ) + + +# -------------------------------------------------------------------------- +# factories +# -------------------------------------------------------------------------- +def uipath_ixp_extraction_tool( + name: str, + *, + project_name: str, + version_tag: str, + description: str | None = None, + input_schema: dict[str, Any] | None = None, + tool_name: str | None = None, +) -> UiPathIxpExtractionTool: + """A durable tool that extracts a document's fields with an IXP project. + + Args: + name: Display name (the ``ixpTool`` span, and what a + :func:`uipath_document_validation_tool` names as its + ``extraction_tool``); the model-facing name is it sanitized the + way low-code agents do, unless ``tool_name`` is given. + project_name: The IXP project. + version_tag: The tag of its published model version (e.g. ``live``). + description: Shown to the model. + input_schema: Argument schema with an attachment field; defaults to + one ``attachment``. + tool_name: Model-facing name override. + """ + return UiPathIxpExtractionTool( + name=sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name), + description=description or DEFAULT_EXTRACTION_DESCRIPTION, + project_name=project_name, + version_tag=version_tag, + input_json_schema=copy.deepcopy(input_schema or ixp_extraction_input_schema()), + display_name=name, + ) + + +def uipath_document_validation_tool( + name: str, + *, + extraction_tool: str | UiPathIxpExtractionTool, + storage_bucket_name: str | None = None, + storage_bucket_directory_path: str | None = None, + action_folder: str | None = None, + action_catalog: str | None = None, + priority: str | None = None, + title: str | None = None, + title_from_input: str | None = None, + description: str | None = None, + tool_name: str | None = None, +) -> UiPathDocumentValidationTool: + """A durable tool that sends the last extraction to Validation Station. + + Args: + name: Display name; the model-facing name is it sanitized the way + low-code agents do, unless ``tool_name`` is given. + extraction_tool: The IXP tool (or its display name) to validate. + storage_bucket_name: Storage bucket the document is kept in for the + task. + storage_bucket_directory_path: Directory inside the bucket. + action_folder: Folder of the task and bucket; defaults to the job's. + action_catalog: Action catalog of the task. + priority: ``Low`` / ``Medium`` (default) / ``High`` / ``Critical``. + title: Task title (default ``VS Escalation Task``). + title_from_input: Path of the title in the agent input. + description: Shown to the model. + tool_name: Model-facing name override. + """ + key = ( + extraction_tool.result_key + if isinstance(extraction_tool, UiPathIxpExtractionTool) + else extraction_tool + ) + return UiPathDocumentValidationTool( + name=sanitize_tool_name(tool_name) if tool_name else _lowcode_tool_name(name), + description=description + or f"{DEFAULT_VALIDATION_DESCRIPTION} Call it only after '{key}' has " + "returned its result.", + extraction_tool=key, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + action_folder=action_folder, + action_catalog=action_catalog, + priority=priority, + title=title, + title_from_input=title_from_input, + display_name=name, + ) + + +def ixp_extraction_tool_from_resource( + resource: Any, + *, + tool_name: str | None = None, +) -> UiPathIxpExtractionTool: + """The IXP extraction tool from a low-code ``tool`` resource (``type: "Ixp"``). + + Uses the project name, version tag, description and input schema (the + first attachment field is the document). ``outputSchema`` and + ``settings`` are ignored, as in langchain. + + Raises: + ValueError: The resource is disabled, has no project or tag, or does + not validate. + """ + from uipath.agent.models.agent import AgentIxpExtractionResourceConfig + + if isinstance(resource, Mapping): + data = dict(resource) + data.setdefault("$resourceType", "tool") + if isinstance(data.get("type"), str) and data["type"].lower() == "ixp": + data["type"] = "Ixp" + try: + resource = AgentIxpExtractionResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid IXP tool resource: {e}") from e + label = f"Tool resource '{resource.name}'" + if resource.is_enabled is False: + raise ValueError(f"{label} is disabled; filter it out before building tools.") + props = resource.properties + if not props.project_name: + raise ValueError(f"{label}: Project is required for IXP tool.") + if not props.version_tag: + raise ValueError(f"{label}: Model is required for IXP tool.") + return UiPathIxpExtractionTool( + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=resource.description or DEFAULT_EXTRACTION_DESCRIPTION, + project_name=props.project_name, + version_tag=props.version_tag, + input_json_schema=copy.deepcopy( + resource.input_schema or ixp_extraction_input_schema() + ), + display_name=resource.name, + mock_name=resource.name, + example_calls=list(props.example_calls or []), + ) + + +def vs_escalation_tool_from_resource( + resource: Any, + *, + tool_name: str | None = None, +) -> UiPathDocumentValidationTool: + """The Document Validation tool from a low-code ``escalation`` resource. + + ``escalationType: 1`` (Agent Builder's "Document Validation Task"): the + first channel must be an Action Center channel; its task title (text, + text builder or agent input) and priority are used, with the + ``vsEscalationProperties`` (IXP tool, storage bucket, and the bucket's + folder, which becomes the task's ``action_folder``). Recipients, app, labels and outcome mapping have no effect, as in + langchain. + + Raises: + ValueError: The resource is disabled, has no Action Center channel, + no IXP tool or storage bucket, or does not validate. + """ + from uipath.agent.models.agent import ( + AgentEscalationChannel, + AgentIxpVsEscalationResourceConfig, + DynamicTaskTitle, + TextBuilderTaskTitle, + ) + + label = f"Escalation resource '{getattr(resource, 'name', None) or (resource.get('name') if isinstance(resource, Mapping) else '')}'" + if isinstance(resource, Mapping): + data = dict(resource) + data.setdefault("$resourceType", "escalation") + props = data.get("vsEscalationProperties") or {} + if not (isinstance(props, dict) and props.get("ixpToolId")): + raise ValueError( + f"{label}: IXP tool is required for Document Validation Task." + ) + if not props.get("storageBucketName"): + raise ValueError( + f"{label}: Storage bucket is required for Document Validation Task." + ) + data["vsEscalationProperties"] = { + **props, + "storageBucketFolderPath": props.get("storageBucketFolderPath") or "", + } + try: + resource = AgentIxpVsEscalationResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid document validation resource: {e}") from e + label = f"Escalation resource '{resource.name}'" + if resource.is_enabled is False: + raise ValueError(f"{label} is disabled; filter it out before building tools.") + if not resource.channels: + raise ValueError(f"{label} has no channels.") + channel = resource.channels[0] + if not isinstance(channel, AgentEscalationChannel): + raise ValueError( + f"VS escalation '{resource.name}' must use an Action Center channel but " + f"received channel type '{_value(getattr(channel, 'type', None))}'." + ) + vs = resource.vs_escalation_properties + if not vs.ixp_tool_id: + raise ValueError(f"{label}: IXP tool is required for Document Validation Task.") + if not vs.storage_bucket_name: + raise ValueError( + f"{label}: Storage bucket is required for Document Validation Task." + ) + title: str | None = None + title_from_input: str | None = None + title_tokens: list[Any] | None = None + if isinstance(channel.task_title, TextBuilderTaskTitle): + title_tokens = list(channel.task_title.tokens) + elif isinstance(channel.task_title, DynamicTaskTitle): + title_from_input = channel.task_title.argument_path + elif isinstance(channel.task_title, str) and channel.task_title: + title = channel.task_title + ignored = [ + name + for name, value in ( + ("recipients", getattr(channel, "recipients", None)), + ("labels", getattr(channel, "labels", None)), + ("outcomeMapping", getattr(channel, "outcome_mapping", None)), + ) + if value + ] + if ignored: + logger.info( + "%s: %s have no effect on a Document Validation Task.", + label, + ", ".join(ignored), + ) + display = getattr(channel.properties, "app_name", None) or _lowcode_tool_name( + resource.name + ) + return UiPathDocumentValidationTool( + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=resource.description or DEFAULT_VALIDATION_DESCRIPTION, + extraction_tool=vs.ixp_tool_id, + storage_bucket_name=vs.storage_bucket_name, + # Agent Builder stores the bucket's Orchestrator folder here (langchain + # sends it as a directory inside the bucket, where DU does not find + # the bucket). + action_folder=vs.storage_bucket_folder_path or None, + priority=_value(channel.priority) if channel.priority else None, + title=title, + title_from_input=title_from_input, + title_tokens=title_tokens, + display_name=display, + mock_name=resource.name, + ) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/mcp.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/mcp.py new file mode 100644 index 00000000..6932f218 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/mcp.py @@ -0,0 +1,243 @@ +"""MCP server declarations for Codex agents. + +Codex loads MCP servers from its config. We pass them as ``--config`` overrides +(the same channel used for the UiPath model_provider), which Codex parses as +TOML — e.g. a stdio server becomes:: + + [mcp_servers.] + command = "python3" + args = ["server.py"] + [mcp_servers..env] + KEY = "value" +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +__all__ = [ + "StdioMcpServer", + "RemoteMcpServer", + "McpServer", + "build_mcp_config_overrides", + "build_mcp_env", + "missing_bearer_token_env_vars", +] + + +@dataclass +class StdioMcpServer: + """A local MCP server Codex launches as a stdio subprocess. + + Args: + command: Executable to launch (e.g. ``sys.executable`` or ``"npx"``). + args: Arguments passed to the command. + env: Extra environment variables for the server process. + default_tools_approval_mode: Codex approval mode for this server's + tools: ``"auto"`` (Codex default: approval required unless the tool + is annotated ``readOnlyHint``), ``"prompt"``, ``"writes"`` or + ``"approve"`` (never ask). With the runtime's ``approval_mode= + "deny_all"`` any tool that needs approval is rejected, so trusted + servers whose tools carry no annotations (e.g. UiPath MCP) need + ``"approve"``. + tool_approval_modes: Per-tool override of the above, ``{tool: mode}``. + omit_tools_from: Model-facing surfaces this server's tools are kept + off: ``"direct"`` (initial tool list), ``"deferred"`` (found via + tool search) and ``"code_mode"`` (nested calls from ``exec`` + scripts). E.g. ``["deferred", "code_mode"]`` makes the tools always + directly callable. + supports_parallel_tool_calls: Whether Codex may run several of this + server's tool calls from one model response at the same time. + Codex serializes MCP calls by default; ``True`` lets them overlap. + """ + + command: str + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + default_tools_approval_mode: str | None = None + tool_approval_modes: dict[str, str] | None = None + supports_parallel_tool_calls: bool | None = None + omit_tools_from: list[str] | None = None + + +@dataclass +class RemoteMcpServer: + """A remote (streamable-HTTP) MCP server Codex connects to over the network. + + Args: + url: Streamable-HTTP endpoint of the MCP server (e.g. + ``"https://host/mcp"``). + bearer_token_env_var: Name of an environment variable Codex reads and + sends as ``Authorization: Bearer ``. For a UiPath + AgentHub-hosted MCP server this is typically + ``"UIPATH_ACCESS_TOKEN"`` (already present in the environment). NOTE: + if this names a variable that is NOT set in the codex process + environment, codex silently skips the server (no tools). Either + ensure the variable is exported, or pass ``bearer_token`` below. + bearer_token: Optional literal token value. When set (together with + ``bearer_token_env_var``), the runtime injects it into the codex + process environment under that variable name, so the server is + reachable without relying on an ambient env var. + http_headers: Additional static HTTP headers to send on every request. + startup_timeout_sec: How long codex waits for the server to initialize + and list its tools. + tool_timeout_sec: Per tool-call timeout (codex default: 300s). UiPath + MCP tools that start an Orchestrator job can take longer. + default_tools_approval_mode: Codex approval mode for this server's + tools: ``"auto"`` (Codex default: approval required unless the tool + is annotated ``readOnlyHint``), ``"prompt"``, ``"writes"`` or + ``"approve"`` (never ask). With the runtime's ``approval_mode= + "deny_all"`` any tool that needs approval is rejected, so trusted + servers whose tools carry no annotations (e.g. UiPath MCP) need + ``"approve"``. + tool_approval_modes: Per-tool override of the above, ``{tool: mode}``. + omit_tools_from: Model-facing surfaces this server's tools are kept + off: ``"direct"`` (initial tool list), ``"deferred"`` (found via + tool search) and ``"code_mode"`` (nested calls from ``exec`` + scripts). E.g. ``["deferred", "code_mode"]`` makes the tools always + directly callable. + supports_parallel_tool_calls: Whether Codex may run several of this + server's tool calls from one model response at the same time. + Codex serializes MCP calls by default; ``True`` lets them overlap. + """ + + url: str + bearer_token_env_var: str | None = None + bearer_token: str | None = None + http_headers: dict[str, str] | None = None + startup_timeout_sec: float | None = None + tool_timeout_sec: float | None = None + default_tools_approval_mode: str | None = None + tool_approval_modes: dict[str, str] | None = None + supports_parallel_tool_calls: bool | None = None + omit_tools_from: list[str] | None = None + + +#: Any MCP server declaration accepted by ``UiPathCodexAgent.mcp_servers``. +McpServer = StdioMcpServer | RemoteMcpServer + + +def _toml_str(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _toml_str_list(values: list[str]) -> str: + return "[" + ", ".join(_toml_str(v) for v in values) + "]" + + +def _toml_inline_table(mapping: dict[str, str]) -> str: + items = ", ".join(f"{_toml_str(k)} = {_toml_str(v)}" for k, v in mapping.items()) + return "{" + items + "}" + + +def build_mcp_config_overrides( + servers: dict[str, McpServer] | None, +) -> tuple[str, ...]: + """Build Codex ``--config`` override strings defining the given MCP servers.""" + if not servers: + return () + overrides: list[str] = [] + for name, server in servers.items(): + prefix = f"mcp_servers.{name}" + if isinstance(server, RemoteMcpServer): + overrides.append(f"{prefix}.url={_toml_str(server.url)}") + if server.bearer_token_env_var: + overrides.append( + f"{prefix}.bearer_token_env_var={_toml_str(server.bearer_token_env_var)}" + ) + if server.http_headers: + overrides.append( + f"{prefix}.http_headers={_toml_inline_table(server.http_headers)}" + ) + if server.startup_timeout_sec is not None: + overrides.append( + f"{prefix}.startup_timeout_sec={float(server.startup_timeout_sec)}" + ) + if server.tool_timeout_sec is not None: + overrides.append( + f"{prefix}.tool_timeout_sec={float(server.tool_timeout_sec)}" + ) + else: # StdioMcpServer + overrides.append(f"{prefix}.command={_toml_str(server.command)}") + if server.args: + overrides.append(f"{prefix}.args={_toml_str_list(server.args)}") + if server.env: + overrides.append(f"{prefix}.env={_toml_inline_table(server.env)}") + overrides.extend(_approval_overrides(prefix, server)) + if server.supports_parallel_tool_calls is not None: + flag = "true" if server.supports_parallel_tool_calls else "false" + overrides.append(f"{prefix}.supports_parallel_tool_calls={flag}") + if server.omit_tools_from is not None: + for surface in server.omit_tools_from: + if surface not in _SURFACES: + raise ValueError( + f"invalid omit_tools_from surface {surface!r}; expected one of {_SURFACES}" + ) + overrides.append( + f"{prefix}.omit_tools_from={_toml_str_list(server.omit_tools_from)}" + ) + return tuple(overrides) + + +_APPROVAL_MODES = ("auto", "prompt", "writes", "approve") +_SURFACES = ("direct", "deferred", "code_mode") + + +def _check_mode(mode: str) -> str: + if mode not in _APPROVAL_MODES: + raise ValueError( + f"invalid MCP approval mode {mode!r}; expected one of {_APPROVAL_MODES}" + ) + return mode + + +def _approval_overrides(prefix: str, server: McpServer) -> list[str]: + out: list[str] = [] + if server.default_tools_approval_mode is not None: + mode = _check_mode(server.default_tools_approval_mode) + out.append(f"{prefix}.default_tools_approval_mode={_toml_str(mode)}") + for tool, mode in (server.tool_approval_modes or {}).items(): + out.append( + f"{prefix}.tools.{tool}.approval_mode={_toml_str(_check_mode(mode))}" + ) + return out + + +def build_mcp_env(servers: dict[str, McpServer] | None) -> dict[str, str]: + """Env vars to inject into the codex process for remote MCP bearer tokens. + + For each :class:`RemoteMcpServer` that carries a literal ``bearer_token`` + plus a ``bearer_token_env_var``, map the variable name to the value so + codex can send it as ``Authorization: Bearer``. + """ + env: dict[str, str] = {} + for server in (servers or {}).values(): + if ( + isinstance(server, RemoteMcpServer) + and server.bearer_token is not None + and server.bearer_token_env_var + ): + env[server.bearer_token_env_var] = server.bearer_token + return env + + +def missing_bearer_token_env_vars( + servers: dict[str, McpServer] | None, environ: dict[str, str] +) -> dict[str, str]: + """Map server name → unresolved bearer_token_env_var. + + A remote server whose ``bearer_token_env_var`` is neither given as a literal + ``bearer_token`` nor present in ``environ`` will be silently skipped by + codex; callers should warn about these. + """ + missing: dict[str, str] = {} + for name, server in (servers or {}).items(): + if not isinstance(server, RemoteMcpServer): + continue + var = server.bearer_token_env_var + if not var: + continue + if server.bearer_token is None and not environ.get(var): + missing[name] = var + return missing diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/middlewares.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/middlewares.py new file mode 100644 index 00000000..7ff5cddb --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/middlewares.py @@ -0,0 +1,8 @@ +from uipath._cli.middlewares import Middlewares + +from ._cli.cli_new import codex_new_middleware + + +def register_middleware(): + """Called by the entry point system when uipath-codex-sdk is installed.""" + Middlewares.register("new", codex_new_middleware) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/process_tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/process_tools.py new file mode 100644 index 00000000..6c783cff --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/process_tools.py @@ -0,0 +1,162 @@ +"""Process-family tools from a low-code ``agent.json`` resource. + +:func:`process_tool_from_resource` turns a ``tool`` resource of type +Process, Agent, Api, ProcessOrchestration, Flow or Function (every one of +them starts an Orchestrator job, as in langchain's ``create_process_tool``) +into a :class:`~uipath_codex_sdk.tools.UiPathProcessTool`, with its +``argumentProperties`` pre-filled. + +Typical use:: + + import json + from uipath_codex_sdk import UiPathCodexAgent, process_tool_from_resource + + resources = json.load(open("agent.json"))["resources"] + invoice = next(r for r in resources if r["name"] == "Create Invoice") + agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Create the invoice for {customer}.", + tools=[process_tool_from_resource(invoice)], + ) +""" + +from __future__ import annotations + +import copy +import logging +from typing import TYPE_CHECKING, Any, Literal, Mapping + +from pydantic import ValidationError + +from ._fixed_args import fixed_args_from_properties +from .integration_tools import _lowcode_tool_name +from .tools import UiPathProcessTool, _description, sanitize_tool_name + +if TYPE_CHECKING: + from uipath.agent.models.agent import AgentProcessToolResourceConfig + +logger = logging.getLogger(__name__) + +__all__ = ["process_tool_from_resource"] + +#: agent.json ``type`` (any case) → the canonical ``AgentToolType`` value. +_PROCESS_TYPES = { + "agent": "Agent", + "process": "Process", + "api": "Api", + "processorchestration": "ProcessOrchestration", + "flow": "Flow", + "function": "Function", +} +#: Resource type → ``toolType`` of the ``Tool call`` span (low-code's +#: ``get_tool_type_value``; a Flow falls through to its ``Integration``). +_TOOL_TYPES = { + "Agent": "Agent", + "Process": "Process", + "Api": "Api", + "ProcessOrchestration": "agenticProcess", + "Flow": "Integration", + "Function": "Function", +} + + +def process_tool_from_resource( + resource: AgentProcessToolResourceConfig | Mapping[str, Any], + *, + tool_name: str | None = None, + run_as_me: bool = False, + folder_path: str | None = None, +) -> UiPathProcessTool: + """A durable tool from a low-code process-family ``tool`` resource. + + Maps the process name (also the ``process.`` binding key), the + tool name (sanitized like low-code: ``Basic RPA Process`` becomes + ``Basic_RPA_Process``), description, input/output schemas, example calls + and ``argumentProperties`` (static, argument, text builder and array + builder; JSONPath keys such as ``$['a']['b']``). The job runs in the + run's folder (``UIPATH_FOLDER_PATH``) as in low-code: the resource's + ``folderPath`` is ignored, and bindings redirect the process. Timeout, + retry and conversational-confirmation settings are ignored. + + Args: + resource: An ``AgentProcessToolResourceConfig``, or the resource dict + as found in a low-code ``agent.json`` (``"$resourceType": + "tool"``, ``"type"`` in any case). + tool_name: Model-facing name; defaults to the resource name sanitized. + run_as_me: Run the child job under the calling user's identity. + folder_path: Folder of the release, instead of the run's folder. + + Raises: + ValueError: The resource does not validate (another tool type, a + ``dynamic`` argument property), is disabled, has no + ``processName``, or has an object-builder argument property no + parent path covers. + """ + from uipath.agent.models.agent import ( + AgentProcessToolResourceConfig, + AgentToolType, + ) + + if isinstance(resource, Mapping): + data = dict(resource) + data.setdefault("$resourceType", "tool") + raw_type = data.get("type") + data["type"] = _PROCESS_TYPES.get(str(raw_type or "").lower(), raw_type) + try: + resource = AgentProcessToolResourceConfig.model_validate(data) + except ValidationError as e: + raise ValueError(f"Not a valid process tool resource: {e}") from e + if not isinstance(resource, AgentProcessToolResourceConfig): + raise ValueError(f"Not a process tool resource: {type(resource).__name__}") + label = f"Tool resource '{resource.name}'" + if resource.is_enabled is False: + raise ValueError(f"{label} is disabled; filter it out before building tools.") + props = resource.properties + process_name = props.process_name + if not process_name: + raise ValueError(f"{label} has no 'processName'.") + resource_type = str(getattr(resource.type, "value", resource.type)) + kind: Literal["process", "agent"] = ( + "agent" if resource.type == AgentToolType.AGENT else "process" + ) + if props.folder_path and not folder_path: + logger.debug( + "%s: folderPath '%s' is ignored; the job runs in the run's folder.", + label, + props.folder_path, + ) + settings = resource.settings + if any( + v is not None + for v in (settings.timeout, settings.max_attempts, settings.retry_delay) + ): + logger.debug("%s: timeout and retry settings are ignored.", label) + input_schema = copy.deepcopy(resource.input_schema) + output_schema = ( + copy.deepcopy(resource.output_schema) + if isinstance(resource.output_schema, dict) + and resource.output_schema.get("properties") + else None + ) + return UiPathProcessTool( + process_name=process_name, + name=sanitize_tool_name(tool_name) + if tool_name + else _lowcode_tool_name(resource.name), + description=_description( + resource.description or None, process_name, kind, output_schema + ), + folder_path=folder_path, + input_json_schema=input_schema, + output_json_schema=output_schema, + run_as_me=run_as_me, + kind=kind, + mock_name=resource.name, + example_calls=list(props.example_calls or []), + fixed_args=fixed_args_from_properties( + label, resource.argument_properties, schema=input_schema, strict=True + ), + tool_type=_TOOL_TYPES.get(resource_type), + display_name=process_name, + mock_child_span=resource_type != "Flow", + ) diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py new file mode 100644 index 00000000..9961a9bf --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py @@ -0,0 +1,28 @@ +"""UiPath Codex SDK Runtime.""" + +from uipath.runtime import ( + UiPathRuntimeContext, + UiPathRuntimeFactoryProtocol, + UiPathRuntimeFactoryRegistry, +) + +from .factory import UiPathCodexRuntimeFactory +from .runtime import UiPathCodexRuntime + + +def register_runtime_factory() -> None: + """Register the Codex SDK factory. Called automatically via entry point.""" + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return UiPathCodexRuntimeFactory(context=context or UiPathRuntimeContext()) + + UiPathRuntimeFactoryRegistry.register("codex", create_factory, "codex.json") + + +__all__ = [ + "register_runtime_factory", + "UiPathCodexRuntimeFactory", + "UiPathCodexRuntime", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/config.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/config.py new file mode 100644 index 00000000..4f0be6f6 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/config.py @@ -0,0 +1,56 @@ +"""Configuration loader for the Codex SDK integration (codex.json).""" + +from __future__ import annotations + +import json +import os + + +class CodexConfig: + """Loader for codex.json configuration. + + Format: ``{"agents": {"agent": "main.py:agent"}}`` + """ + + def __init__(self, config_path: str = "codex.json"): + self.config_path = config_path + self._agents: dict[str, str] | None = None + + @property + def exists(self) -> bool: + return os.path.exists(self.config_path) + + @property + def agents(self) -> dict[str, str]: + if self._agents is None: + self._agents = self._load_agents() + return self._agents + + @property + def entrypoint(self) -> list[str]: + return list(self.agents.keys()) + + def _load_agents(self) -> dict[str, str]: + if not self.exists: + raise FileNotFoundError( + f"Codex configuration file not found at {self.config_path}" + ) + + try: + with open(self.config_path, "r") as f: + config = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in '{self.config_path}': {e}") from e + + agents = config.get("agents") + if not isinstance(agents, dict) or not all( + isinstance(v, str) for v in agents.values() + ): + raise ValueError( + "Missing or invalid 'agents' key in codex.json configuration file. " + 'Expected {"agents": {"name": "file.py:variable"}}.' + ) + return agents + + +__all__ = ["CodexConfig"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/errors.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/errors.py new file mode 100644 index 00000000..7b591327 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/errors.py @@ -0,0 +1,82 @@ +"""Error types for the UiPath Codex SDK runtime.""" + +from __future__ import annotations + +from enum import Enum + +from uipath.runtime.errors import ( + UiPathBaseRuntimeError, + UiPathErrorCategory, + UiPathRuntimeError, +) + + +class UiPathCodexErrorCode(str, Enum): + """Stable error codes surfaced by the Codex SDK runtime.""" + + CONFIG_MISSING = "CONFIG_MISSING" + CONFIG_INVALID = "CONFIG_INVALID" + AGENT_NOT_FOUND = "AGENT_NOT_FOUND" + AGENT_IMPORT_ERROR = "AGENT_IMPORT_ERROR" + AGENT_LOAD_FAILURE = "AGENT_LOAD_FAILURE" + AGENT_TYPE_ERROR = "AGENT_TYPE_ERROR" + INPUT_INVALID = "INPUT_INVALID" + EXECUTION_FAILED = "EXECUTION_FAILED" + OUTPUT_INVALID = "OUTPUT_INVALID" + FILE_ERROR = "FILE_ERROR" + IXP_FAILED = "IXP_FAILED" + + # durable (suspend/resume) tools + SESSION_NOT_FOUND = "SESSION_NOT_FOUND" + SESSION_MISMATCH = "SESSION_MISMATCH" + ENTRYPOINT_MISMATCH = "ENTRYPOINT_MISMATCH" + ROLLOUT_CHECKPOINT_FAILED = "ROLLOUT_CHECKPOINT_FAILED" + SUSPEND_KILL_UNAVAILABLE = "SUSPEND_KILL_UNAVAILABLE" + TOOL_NOT_FOUND = "TOOL_NOT_FOUND" + TERMINATION_ESCALATION_REJECTED = "TERMINATION_ESCALATION_REJECTED" + + +class UiPathCodexRuntimeError(UiPathRuntimeError): + """Runtime error raised by the Codex SDK integration.""" + + def __init__( + self, + code: UiPathCodexErrorCode, + title: str, + detail: str, + category: UiPathErrorCategory = UiPathErrorCategory.USER, + ) -> None: + # UiPathRuntimeError types its code as the SDK's own enum; ours has the + # same shape, so go straight to the base with the same "Python" prefix. + UiPathBaseRuntimeError.__init__( + self, + code=code.value, + title=title, + detail=detail, + category=category, + prefix="Python", + ) + + +class UiPathRunTerminatedError(UiPathCodexRuntimeError): + """A durable tool's result ends the run (an escalation outcome mapped to "end"). + + Raised by ``finish``; the runtime closes the call's spans with + ``result_text`` and faults the run with this error. + """ + + def __init__(self, title: str, detail: str, result_text: str) -> None: + super().__init__( + UiPathCodexErrorCode.TERMINATION_ESCALATION_REJECTED, + title, + detail, + UiPathErrorCategory.USER, + ) + self.result_text = result_text + + +__all__ = [ + "UiPathCodexErrorCode", + "UiPathCodexRuntimeError", + "UiPathRunTerminatedError", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py new file mode 100644 index 00000000..5a6cd631 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py @@ -0,0 +1,601 @@ +"""Factory for creating Codex SDK runtimes from codex.json configuration.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shutil +from typing import Any, AsyncGenerator + +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.trace import Span, Status, StatusCode +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathResumableRuntime, + UiPathRuntimeContext, + UiPathRuntimeEvent, + UiPathRuntimeFactorySettings, + UiPathRuntimeProtocol, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathRuntimeStorageProtocol, + UiPathStreamOptions, +) +from uipath.runtime.errors import UiPathErrorCategory + +from ..agent import UiPathCodexAgent +from ..attachments import span_attachments +from ..tools import is_durable_tool +from .config import CodexConfig +from .errors import UiPathCodexErrorCode, UiPathCodexRuntimeError +from .loader import CodexAgentLoader +from .runtime import ( + CODEX_HOME_DIR, + CONTINUE_PROMPT, + DURABLE_CALLS_METADATA, + UiPathCodexRuntime, +) +from .schema import get_entrypoint_schema +from .session_store import CodexSessionStore +from .storage import SqliteResumableStorage +from .telemetry import ( + attribute_values, + complete_span, + emit_agent_output, + enable_gateway_model_spans, + fail_span, + propagated_attributes, + push_running, + reset_resumed_attributes, + restored_span, + resumed_attributes, + set_attributes, + span_record, + start_agent_run, +) +from .triggers import CodexResumeTriggerHandler + +logger = logging.getLogger(__name__) + +_AGENT_TYPE_CODED = "uipath_coded" +_AGENT_FRAMEWORK = "codex_sdk" +_DEFAULT_RUNTIME_DIR = "__uipath" +_DEFAULT_STATE_FILE = "state.db" +_STATE_FILE_SIDECARS = ("-wal", "-shm") + + +class UiPathCodexRuntimeFactory: + """Discovers entrypoints from codex.json and builds Codex runtimes. + + Every runtime is wrapped in ``UiPathResumableRuntime`` over a SQLite state + database, so a durable-tool suspension is persisted as resume triggers and + restored on the next ``--resume`` (or job resume). + """ + + def __init__(self, context: UiPathRuntimeContext): + self.context = context + # Let the LLM Gateway record the Model run spans (under our LLM call + # spans), as for every other UiPath agent. + enable_gateway_model_spans() + self._config: CodexConfig | None = None + self._agent_cache: dict[str, UiPathCodexAgent] = {} + self._agent_loaders: dict[str, CodexAgentLoader] = {} + self._lock = asyncio.Lock() + self._storage_lock = asyncio.Lock() + self._storage: SqliteResumableStorage | None = None + self._codex_home: str | None = None + + def _load_config(self) -> CodexConfig: + if self._config is None: + self._config = CodexConfig() + return self._config + + def discover_entrypoints(self) -> list[str]: + config = self._load_config() + if not config.exists: + return [] + return config.entrypoint + + async def get_storage(self) -> UiPathRuntimeStorageProtocol | None: + return await self._get_storage() + + async def get_settings(self) -> UiPathRuntimeFactorySettings | None: + return UiPathRuntimeFactorySettings( + agent_type=_AGENT_TYPE_CODED, + agent_framework=_AGENT_FRAMEWORK, + ) + + async def new_runtime( + self, entrypoint: str, runtime_id: str, **kwargs: Any + ) -> UiPathRuntimeProtocol: + agent = await self._resolve_agent(entrypoint) + loader = self._agent_loaders[entrypoint] + storage = await self._get_storage() + delegate = UiPathCodexRuntime( + agent=agent, + runtime_id=runtime_id, + entrypoint=entrypoint, + file_path=os.path.abspath(loader.file_path), + storage=storage, + # Only durable agents run on a run-scoped CODEX_HOME; the others + # (immediate tools only, too) keep codex's own resolution (and + # nothing of theirs is wiped). + codex_home=( + self._get_codex_home() + if any(is_durable_tool(t) for t in agent.tools or []) + and not agent.codex_home + else None + ), + ) + return UiPathCodexResumableRuntime( + delegate=delegate, + storage=storage, + trigger_manager=CodexResumeTriggerHandler(), + runtime_id=runtime_id, + ) + + async def dispose(self) -> None: + for loader in self._agent_loaders.values(): + await loader.cleanup() + self._agent_loaders.clear() + self._agent_cache.clear() + async with self._storage_lock: + storage, self._storage = self._storage, None + if storage is not None: + await storage.dispose() + + # --- state ------------------------------------------------------------ + + def _should_reset_state(self) -> bool: + """Whether a run starts from empty state (state.db and CODEX_HOME). + + langchain's rule: only the ``runtime_dir`` + ``state_file`` location is + reset (never an explicit ``state_file_path``), and only on a fresh + local run. A resumed run either carries the resume flag or runs under + a job id, and reads its session out of the state database. + """ + return ( + self.context.state_file_path is None + and bool(self.context.runtime_dir) + and bool(self.context.state_file) + and not self.context.resume + and self.context.job_id is None + and not self.context.keep_state_file + ) + + def _runtime_dir(self) -> str: + return self.context.runtime_dir or _DEFAULT_RUNTIME_DIR + + def _get_storage_path(self) -> str: + if self.context.state_file_path is not None: + return self.context.state_file_path + if not (self.context.runtime_dir and self.context.state_file): + os.makedirs(_DEFAULT_RUNTIME_DIR, exist_ok=True) + return os.path.join(_DEFAULT_RUNTIME_DIR, _DEFAULT_STATE_FILE) + path = os.path.join(self.context.runtime_dir, self.context.state_file) + if self._should_reset_state(): + for candidate in (path, *(path + s for s in _STATE_FILE_SIDECARS)): + if os.path.exists(candidate): + try: + os.remove(candidate) + except OSError: + logger.warning("Could not reset the state file %s", candidate) + os.makedirs(self.context.runtime_dir, exist_ok=True) + return path + + async def _get_storage(self) -> SqliteResumableStorage: + if self._storage is not None: + return self._storage + async with self._storage_lock: + if self._storage is None: + self._storage = SqliteResumableStorage(self._get_storage_path()) + return self._storage + + def _get_codex_home(self) -> str: + """Run-scoped ``CODEX_HOME``, reset under the same rule as state.db.""" + if self._codex_home is None: + path = os.path.abspath(os.path.join(self._runtime_dir(), CODEX_HOME_DIR)) + if self._should_reset_state() and os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + os.makedirs(path, exist_ok=True) + self._codex_home = path + return self._codex_home + + # --- helpers ---------------------------------------------------------- + + async def _resolve_agent(self, entrypoint: str) -> UiPathCodexAgent: + async with self._lock: + if entrypoint in self._agent_cache: + return self._agent_cache[entrypoint] + + config = self._load_config() + if not config.exists: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_MISSING, + "Invalid configuration", + "Failed to load codex.json configuration", + UiPathErrorCategory.DEPLOYMENT, + ) + if entrypoint not in config.agents: + available = ", ".join(config.entrypoint) + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_NOT_FOUND, + "Agent not found", + f"Agent '{entrypoint}' not found. Available: {available}", + UiPathErrorCategory.DEPLOYMENT, + ) + + loader = CodexAgentLoader.from_path_string( + entrypoint, config.agents[entrypoint] + ) + self._agent_loaders[entrypoint] = loader + agent = await loader.load() + self._agent_cache[entrypoint] = agent + return agent + + +class UiPathCodexResumableRuntime(UiPathResumableRuntime): + """``UiPathResumableRuntime`` that traces the job and reports a suspension + compactly. + + A job is one ``Agent run`` span across all its executions, as for a + low-code agent. It is current while the resume triggers are read and the + Codex run executes, so every span of the job is under it: + + * the first execution opens it. On a suspension it is not ended: it is + pushed to LLMOps as still running (``status=suspended``) and its + identity is saved in the state database; + * a resumed execution parents its spans directly under the saved span, + as low-code agents do; + * the execution that ends the job completes the saved span (same id and + start time) with ``status`` = successful or faulted, after an ``Agent + output`` span on success. + + A fresh (non-resume) execution forgets any saved span. An in-process + resume (fired triggers) runs inside the execution's span. + + The delegate's suspended ``output`` must stay the typed suspend values + (``WaitJobRaw``...): the base class turns them into resume triggers. Once + the triggers exist, the returned result's ``output`` is only what + ``uipath run`` prints and what lands in ``output.json`` (Orchestrator + resumes from ``resumeTriggers``), so it is replaced by + ``{call_id: {tool, arguments, job_key, job_state, folder_key}}``. + """ + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + run = await self._open_agent_run(input, options) + try: + result = await super().execute(input, options) + await run.finish(result) + return result + except BaseException as e: + await run.fail(e) + raise + finally: + run.detach() + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + run = await self._open_agent_run(input, options) + try: + async for event in super().stream(input, options): + if isinstance(event, UiPathRuntimeResult): + await run.finish(event) + yield event + except BaseException as e: + await run.fail(e) + raise + finally: + run.detach() + + def _trace_store(self) -> CodexSessionStore | None: + if not hasattr(self.storage, "get_value"): # pragma: no cover + return None + return CodexSessionStore(self.storage, self.runtime_id) # type: ignore[arg-type] + + async def _open_agent_run(self, input: Any, options: Any) -> _AgentRun: + # Read before the run: the in-process resume loop sets ``resume``. + resumed = bool(options is not None and getattr(options, "resume", False)) + store = self._trace_store() + saved: dict[str, Any] | None = None + if store is not None: + try: + saved = await store.get_trace() + if not resumed and saved is not None: + await store.set_trace(None) + saved = None + except Exception: + logger.warning("Could not read the saved trace context", exc_info=True) + saved = None + delegate = self.delegate + # Before the restored root becomes current: its children cannot + # inherit ``execution.id`` from a non-recording parent. + propagated = propagated_attributes() + root: Span | None = None + if saved is not None: + try: + root = restored_span(saved) + except (KeyError, TypeError, ValueError): + logger.warning("Ignoring an unusable saved trace context") + if store is not None: + try: + await store.set_trace(None) + except Exception: + logger.warning("Could not clear the saved trace context") + if saved is None or root is None: + # A new Agent run (also on resume when nothing was saved). + return _AgentRun( + self._start_agent_span(input, options), + store=store, + delegate=delegate, + ) + return _AgentRun( + root, store=store, delegate=delegate, saved=saved, propagated=propagated + ) + + def _start_agent_span(self, input: Any, options: Any) -> Span: + runtime = self.delegate + resumed = bool(options is not None and getattr(options, "resume", False)) + if not isinstance(runtime, UiPathCodexRuntime): # pragma: no cover + return trace.get_tracer(__name__).start_span("Agent run") + agent = runtime.agent + user_prompt: str | None = CONTINUE_PROMPT if resumed else None + if not resumed: + try: + user_prompt = runtime._render_prompt(input or {}) + except Exception: + user_prompt = None # invalid input: the run itself reports it + schema = get_entrypoint_schema(agent, runtime.entrypoint) + return start_agent_run( + runtime.entrypoint, + agent_name=agent.name if agent.name != "agent" else runtime.entrypoint, + model=agent.model, + input=None if resumed else input, + system_prompt=agent.base_instructions, + user_prompt=user_prompt, + input_schema=schema.input, + output_schema=schema.output, + resumed=resumed, + attachments=( + None + if resumed + else span_attachments(runtime.input_attachments(input or {}), "in") + ), + ) + + async def _handle_suspension( + self, result: UiPathRuntimeResult + ) -> UiPathRuntimeResult: + handled = await super()._handle_suspension(result) + if handled.status != UiPathRuntimeStatus.SUSPENDED or not isinstance( + result.output, dict + ): + return handled + calls = (result.metadata or {}).get(DURABLE_CALLS_METADATA) or {} + summary: dict[str, Any] = {} + for call_id, value in result.output.items(): + entry: dict[str, Any] = dict(calls.get(call_id) or {}) + job = getattr(value, "job", None) + if job is not None: + entry["job_key"] = getattr(job, "key", None) + state = getattr(job, "state", None) + entry["job_state"] = getattr(state, "value", state) + deep_rag = getattr(value, "deep_rag", None) + if deep_rag is not None: + entry["deep_rag_id"] = getattr(deep_rag, "id", None) + entry["deep_rag_status"] = getattr( + deep_rag, "last_deep_rag_status", None + ) + extraction = getattr(value, "extraction", None) + if extraction is not None and hasattr(extraction, "operation_id"): + entry["extraction_id"] = extraction.operation_id + entry["document_id"] = getattr(extraction, "document_id", None) + entry["project_id"] = getattr(extraction, "project_id", None) + validation = getattr(value, "extraction_validation", None) + if validation is not None: + entry["validation_operation_id"] = validation.operation_id + entry["task_url"] = getattr(value, "task_url", None) + batch = getattr(value, "batch_transform", None) + if batch is not None: + entry["batch_transform_id"] = getattr(batch, "id", None) + entry["batch_transform_status"] = getattr( + batch, "last_batch_rag_status", None + ) + task = getattr(value, "action", None) + if task is not None and hasattr(task, "is_deleted"): + entry["task_id"] = getattr(task, "id", None) + entry["task_key"] = getattr(task, "key", None) + entry["task_title"] = getattr(task, "title", None) + entry["app_name"] = getattr(value, "app_name", None) + folder_key = ( + getattr(value, "process_folder_key", None) + or getattr(value, "index_folder_key", None) + or getattr(value, "app_folder_key", None) + ) + if folder_key: + entry["folder_key"] = folder_key + entry.setdefault("type", type(value).__name__) + summary[call_id] = entry + handled.output = summary + return handled + + +def _error_text(error: Any) -> str: + if error is None: + return "" + return str(getattr(error, "detail", "") or getattr(error, "title", "") or "") + + +class _AgentRun: + """The job's ``Agent run`` span as one execution sees it. + + Either a live span this execution opened (``saved`` is None), or the span + an earlier execution saved, restored as the (non-recording) parent of this + execution's spans. + """ + + def __init__( + self, + root: Span, + *, + store: CodexSessionStore | None, + delegate: Any, + saved: dict[str, Any] | None = None, + propagated: dict[str, Any] | None = None, + ) -> None: + self.root = root + self.saved = saved + # Attributes this execution's spans carry (``execution.id``) that the + # restored root cannot hand down. + self._propagated = dict(propagated or {}) + self._store = store + self._delegate = delegate + self._done = False + self._token: object | None = context_api.attach(trace.set_span_in_context(root)) + # The restored root cannot hand ``execution.id`` down; set it directly. + self._attributes_token = ( + resumed_attributes(self._propagated) if saved is not None else None + ) + + def _output_attachments(self, output: Any) -> str | None: + runtime = self._delegate + if not isinstance(runtime, UiPathCodexRuntime): + return None + return span_attachments(runtime.output_attachments(output), "out") + + def detach(self) -> None: + if self._attributes_token is not None: + reset_resumed_attributes(self._attributes_token) + self._attributes_token = None + if self._token is not None: + _detach(self._token) + self._token = None + + async def finish(self, result: UiPathRuntimeResult) -> None: + if self._done: + return + self._done = True + if result.status == UiPathRuntimeStatus.SUSPENDED: + try: + await self._suspend(result.output) + except Exception: + logger.warning("Could not suspend the Agent run span", exc_info=True) + return + if result.status == UiPathRuntimeStatus.SUCCESSFUL: + try: + emit_agent_output( + result.output, + context=trace.set_span_in_context(self.root), + attachments=self._output_attachments(result.output), + **self._propagated, + ) + except Exception: + logger.warning("Could not emit the Agent output span", exc_info=True) + await self._safely(self._complete("successful", output=result.output)) + return + status = str(getattr(result.status, "value", result.status)).lower() + error = _error_text(result.error) or status + # Each step on its own: a failing one must not leave the run open. + await self._safely(self._fail_tool_spans(error)) + await self._safely(self._complete(status, error=error)) + + async def fail(self, error: BaseException) -> None: + if self._done: + return + self._done = True + # ``CancelledError()`` and the like have no message: still an error. + text = str(error) or type(error).__name__ + await self._safely(self._fail_tool_spans(text)) + if self.saved is None: + try: + fail_span(self.root, error) + self.root.set_attribute("status", "faulted") + except Exception: + logger.warning("Could not fail the Agent run span", exc_info=True) + finally: + self.root.end() + else: + await self._safely(self._complete("faulted", error=text)) + + @staticmethod + async def _safely(step: Any) -> None: + try: + await step + except Exception: + logger.warning("Could not finish the Agent run trace", exc_info=True) + + async def _suspend(self, output: Any) -> None: + if self.saved is not None: + return # still running; the saved span carries forward unchanged + # Not ended: the next execution completes it. + set_attributes(self.root, status="suspended", output=output) + push_running(self.root) + record = span_record(self.root) + if record is not None and self._store is not None: + await self._store.set_trace(record) + + async def _complete( + self, status: str, *, output: Any = None, error: str | None = None + ) -> None: + if self.saved is None: + try: + set_attributes(self.root, status=status, output=output) + if error is None: + self.root.set_status(Status(StatusCode.OK)) + else: + self.root.set_status(Status(StatusCode.ERROR, error)) + self.root.set_attribute("error", error) + finally: + self.root.end() + return + try: + complete_span( + self.saved, + attributes={ + **self._propagated, + **attribute_values(status=status, output=output), + }, + error=error, + # A faulted run has no output: not the suspension's summary. + drop=("output", "output.value") if output is None else (), + ) + finally: + if self._store is not None: + await self._store.set_trace(None) + + async def _fail_tool_spans(self, error: str) -> None: + if isinstance(self._delegate, UiPathCodexRuntime): + await self._delegate.fail_open_tool_spans( + f"The agent run failed before the job result was used: {error}" + ) + + +def _detach(token: object) -> None: + """Detach the agent run context, quietly when it cannot be. + + A stream closed from another context (an abandoned generator finalised by + the event loop, or ``aclose()`` from another task) cannot reset the + context variable; that context is gone anyway. ``context_api.detach`` + catches that ``ValueError`` itself and logs "Failed to detach context" at + ERROR, so call the runtime context it wraps, which raises instead. + """ + runtime_context = getattr(context_api, "_RUNTIME_CONTEXT", None) + try: + if runtime_context is not None: + runtime_context.detach(token) + else: # pragma: no cover - a future OpenTelemetry without it + context_api.detach(token) # type: ignore[arg-type] + except Exception: + logger.debug("could not detach the agent run context", exc_info=True) + + +__all__ = ["UiPathCodexResumableRuntime", "UiPathCodexRuntimeFactory"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/__init__.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/__init__.py new file mode 100644 index 00000000..c4940c98 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/__init__.py @@ -0,0 +1,165 @@ +"""Routing of Codex's LLM traffic through the UiPath tenant. + +The runtime starts a :class:`CodexGatewayShim`, points the bundled ``codex`` +binary at it via a custom ``model_provider`` (base_url = loopback, wire_api = +responses, env_key = a per-run secret), and the shim relays the Responses-API +calls to the UiPath LLM Gateway. +""" + +from __future__ import annotations + +import asyncio +import logging +import secrets + +from opentelemetry.context import Context +from uipath.llm_client.settings import ( + UiPathBaseSettings, + get_default_client_settings, +) + +from .proxy import MCP_PATH, CodexGatewayProxy, MountedHandler +from .upstream import UpstreamRegistry + +logger = logging.getLogger(__name__) + +STOP_TIMEOUT = 10.0 +_API_KEY_BYTES = 32 + +#: The model_provider id injected into Codex config. +PROVIDER_ID = "uipath" +#: Env var name Codex reads for the provider bearer token (its ``env_key``). +ENV_KEY_NAME = "UIPATH_CODEX_GATEWAY_KEY" + + +class CodexGatewayShim: + """Local listener that routes Codex's LLM calls to the UiPath tenant. + + Args: + default_model: Model used when a request omits one; also the model the + thread is started with. + settings: Optional pre-built LLM client settings (mainly for tests). + mcp_handler: Optional endpoint mounted on ``/mcp`` next to the model + routes (the durable tool host). + trace_context: Context the ``LLM call`` spans are opened in. + """ + + def __init__( + self, + default_model: str | None = None, + *, + settings: UiPathBaseSettings | None = None, + mcp_handler: MountedHandler | None = None, + trace_context: Context | None = None, + ) -> None: + self._default_model = default_model + self._settings = settings + self._mcp_handler = mcp_handler + self._trace_context = trace_context + self._api_key = secrets.token_urlsafe(_API_KEY_BYTES) + self._lock = asyncio.Lock() + self._upstream: UpstreamRegistry | None = None + self._proxy: CodexGatewayProxy | None = None + + async def start(self) -> None: + """Build settings and bind the loopback listener. Idempotent.""" + async with self._lock: + if self._proxy is not None: + return + settings = self._settings + if settings is None: + settings = await asyncio.to_thread(get_default_client_settings) + upstream = UpstreamRegistry(settings) + proxy = CodexGatewayProxy( + upstream=upstream, + api_key=self._api_key, + default_model=self._default_model, + mcp_handler=self._mcp_handler, + trace_context=self._trace_context, + ) + try: + await proxy.start() + except Exception: + await upstream.aclose() + raise + self._settings = settings + self._upstream = upstream + self._proxy = proxy + logger.info("codex gateway shim ready at %s", self.base_url) + + async def stop(self) -> None: + async with self._lock: + proxy, upstream = self._proxy, self._upstream + self._proxy = None + self._upstream = None + if proxy is not None: + try: + await asyncio.wait_for(proxy.stop(), timeout=STOP_TIMEOUT) + except asyncio.TimeoutError: + logger.warning("codex gateway did not drain within %ss", STOP_TIMEOUT) + if upstream is not None: + await upstream.aclose() + + async def __aenter__(self) -> "CodexGatewayShim": + await self.start() + return self + + async def __aexit__(self, *args: object) -> None: + await self.stop() + + # --- surface handed to Codex ----------------------------------------- + + @property + def base_url(self) -> str: + """Loopback base URL for the Codex model_provider (Codex adds /responses).""" + return f"http://127.0.0.1:{self._require_proxy().port}/v1" + + @property + def api_key(self) -> str: + return self._api_key + + @property + def mcp_url(self) -> str: + """URL of the mounted ``/mcp`` endpoint.""" + return f"http://127.0.0.1:{self._require_proxy().port}{MCP_PATH}" + + @property + def inflight(self) -> int: + """Model requests currently being relayed (0 when stopped).""" + return self._proxy.inflight if self._proxy is not None else 0 + + @property + def last_activity(self) -> float: + """``time.monotonic()`` of the last model request start or end.""" + return self._proxy.last_activity if self._proxy is not None else 0.0 + + def config_overrides(self) -> tuple[str, ...]: + """``--config`` overrides that define the UiPath model_provider. + + Retry posture (intentional): Codex's provider-side retries are disabled + here (``request_max_retries``/``stream_max_retries`` = 0) because the + loopback proxy forwards through ``UiPathHttpxAsyncClient``, whose own + retry pipeline already handles transient upstream/gateway failures + (e.g. 503s). Retrying at both layers would compound; the gateway client + owns retries. + """ + return ( + f'model_providers.{PROVIDER_ID}.name="UiPath LLM Gateway"', + f'model_providers.{PROVIDER_ID}.base_url="{self.base_url}"', + f'model_providers.{PROVIDER_ID}.wire_api="responses"', + f'model_providers.{PROVIDER_ID}.env_key="{ENV_KEY_NAME}"', + f"model_providers.{PROVIDER_ID}.request_max_retries=0", + f"model_providers.{PROVIDER_ID}.stream_max_retries=0", + ) + + def env(self) -> dict[str, str]: + """Env vars for the Codex subprocess (the provider bearer token).""" + return {ENV_KEY_NAME: self._api_key} + + def _require_proxy(self) -> CodexGatewayProxy: + if self._proxy is None: + raise RuntimeError("CodexGatewayShim.start() has not completed") + return self._proxy + + +__all__ = ["CodexGatewayShim", "CodexGatewayProxy", "PROVIDER_ID", "ENV_KEY_NAME"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/proxy.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/proxy.py new file mode 100644 index 00000000..92316959 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/proxy.py @@ -0,0 +1,326 @@ +"""Loopback listener that routes Codex's LLM calls to the UiPath gateway. + +The bundled ``codex`` binary (not the Python package) makes the HTTP calls, and +with a custom ``model_provider`` it POSTs the OpenAI **Responses API** to a +configured ``base_url``. A loopback listener is therefore the only way to +intercept them and attach the UiPath auth + routing headers. + +Because the UiPath gateway speaks the Responses API natively (proven: OpenAI +models advertise the ``OpenAiResponses`` flavor), this is a near pass-through +relay — the request/response bodies are forwarded verbatim; only the model is +read from the body to pick the per-model upstream client. +""" + +from __future__ import annotations + +import gzip +import hmac +import json +import logging +import socket +import time +from typing import Any, Protocol + +import httpx +from aiohttp import web +from aiohttp.client_exceptions import ClientConnectionResetError +from opentelemetry.context import Context + +from ..telemetry import ( + ModelCallSpan, + SseCompletedReader, +) +from .upstream import UPSTREAM_PATH, UpstreamRegistry + +# Codex closes the connection the moment it has the final SSE event, which can +# land mid-write on our side. These are benign client disconnects, not errors. +_CLIENT_GONE = (ConnectionResetError, ClientConnectionResetError, RuntimeError) + +logger = logging.getLogger(__name__) + +CLIENT_MAX_SIZE = 100 * 1024 * 1024 +SHUTDOWN_TIMEOUT = 5.0 +GZIP_MAGIC = b"\x1f\x8b" +#: Path the durable tool host (an MCP endpoint) is mounted on. +MCP_PATH = "/mcp" + + +class MountedHandler(Protocol): + """An extra endpoint served next to the model routes (the tool host).""" + + async def handle(self, request: web.Request) -> web.StreamResponse: ... + + +class CodexGatewayProxy: + """aiohttp application bound to 127.0.0.1 on an OS-assigned port. + + Args: + upstream: Per-model upstream client registry. + api_key: Per-run secret Codex must present on every request. + default_model: Model to use when a request body omits ``model``. + mcp_handler: Optional endpoint mounted on ``/mcp`` (the durable tool + host). It does its own authentication, with its own token. + trace_context: Context the ``LLM call`` spans are opened in (the + run's ``Agent run`` span). The handlers run on aiohttp's tasks, so + the parent is passed rather than taken from the current context. + """ + + def __init__( + self, + *, + upstream: UpstreamRegistry, + api_key: str, + default_model: str | None = None, + mcp_handler: MountedHandler | None = None, + trace_context: Context | None = None, + ) -> None: + self._upstream = upstream + self._trace_context = trace_context + self._api_key = api_key.encode() + self._default_model = default_model + self._mcp_handler = mcp_handler + self._socket: socket.socket | None = None + self._runner: web.AppRunner | None = None + self._site: web.SockSite | None = None + self._port = 0 + self._inflight = 0 + self._last_activity = time.monotonic() + + @property + def port(self) -> int: + return self._port + + @property + def inflight(self) -> int: + """Model (``/responses``) requests currently being relayed.""" + return self._inflight + + @property + def last_activity(self) -> float: + """``time.monotonic()`` of the last model request start or end.""" + return self._last_activity + + # --- lifecycle -------------------------------------------------------- + + async def start(self) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + self._port = sock.getsockname()[1] + self._socket = sock + + app = web.Application( + client_max_size=CLIENT_MAX_SIZE, + middlewares=[self._authenticate], + ) + # Codex base_url is "http://127.0.0.1:/v1"; it appends + # "/responses". Accept both with and without the /v1 prefix. + app.router.add_post("/v1/responses", self._handle_responses) + app.router.add_post("/responses", self._handle_responses) + app.router.add_get("/v1/models", self._handle_models) + app.router.add_get("/models", self._handle_models) + if self._mcp_handler is not None: + app.router.add_route("*", MCP_PATH, self._mcp_handler.handle) + + self._runner = web.AppRunner(app, shutdown_timeout=SHUTDOWN_TIMEOUT) + await self._runner.setup() + self._site = web.SockSite(self._runner, sock) + await self._site.start() + except Exception: + sock.close() + self._socket = None + self._port = 0 + await self.stop() + raise + logger.info("codex gateway proxy listening on 127.0.0.1:%d", self._port) + + async def stop(self) -> None: + if self._site is not None: + await self._site.stop() + self._site = None + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + if self._socket is not None: + self._socket.close() + self._socket = None + self._port = 0 + + # --- authentication --------------------------------------------------- + + @web.middleware + async def _authenticate( + self, request: web.Request, handler: Any + ) -> web.StreamResponse: + if self._mcp_handler is not None and request.path == MCP_PATH: + return await handler(request) # the tool host checks its own token + authorization = request.headers.get("Authorization", "") + scheme, _, token = authorization.partition(" ") + presented = token if scheme.lower() == "bearer" else authorization + if not presented: + presented = request.headers.get("x-api-key", "") + if not hmac.compare_digest(presented.encode(), self._api_key): + return _error_response(401, "Invalid API key for the UiPath codex gateway.") + return await handler(request) + + # --- handlers --------------------------------------------------------- + + async def _handle_models(self, request: web.Request) -> web.Response: + data = [] + if self._default_model: + data.append({"id": self._default_model, "object": "model"}) + return web.json_response({"object": "list", "data": data}) + + async def _handle_responses(self, request: web.Request) -> web.StreamResponse: + self._inflight += 1 + self._last_activity = time.monotonic() + try: + return await self._relay_responses(request) + finally: + self._inflight -= 1 + self._last_activity = time.monotonic() + + async def _relay_responses(self, request: web.Request) -> web.StreamResponse: + raw = await _read_body(request) + if raw is None: + return _error_response(400, "Request body is not valid gzip.") + try: + payload = json.loads(raw) + except ValueError: + return _error_response(400, "Request body is not valid JSON.") + if not isinstance(payload, dict): + return _error_response(400, "Request body must be a JSON object.") + + model = str(payload.get("model") or self._default_model or "") + if not model: + return _error_response( + 400, "No model in request and no default configured." + ) + + span = ModelCallSpan(self._trace_context, payload, model) + + client = await self._upstream.get(model) + body = json.dumps(payload).encode() + headers = {"content-type": "application/json", **span.trace_headers()} + try: + if payload.get("stream"): + response, completed = await self._forward_stream( + request, client, body, headers + ) + else: + response, completed = await self._forward_unary(client, body, headers) + except BaseException as exc: + span.finish(error=str(exc) or type(exc).__name__) + raise + status = getattr(response, "status", None) + span.finish( + response=completed, + status=status, + error=None if status == 200 else f"HTTP {status}", + ) + return response + + # --- forwarding ------------------------------------------------------- + + async def _forward_unary( + self, client: httpx.AsyncClient, body: bytes, headers: dict[str, str] + ) -> tuple[web.StreamResponse, dict[str, Any] | None]: + try: + upstream = await client.request( + "POST", UPSTREAM_PATH, headers=headers, content=body + ) + except httpx.HTTPError as exc: + return ( + _error_response(502, f"UiPath LLM Gateway is unreachable: {exc}"), + None, + ) + + completed: dict[str, Any] | None = None + if upstream.status_code == 200: + try: + parsed = upstream.json() + completed = parsed if isinstance(parsed, dict) else None + except ValueError: + completed = None + return ( + web.Response( + status=upstream.status_code, + body=upstream.content, + content_type="application/json", + ), + completed, + ) + + async def _forward_stream( + self, + request: web.Request, + client: httpx.AsyncClient, + body: bytes, + headers: dict[str, str], + ) -> tuple[web.StreamResponse, dict[str, Any] | None]: + reader = SseCompletedReader() + try: + async with client.stream( + "POST", UPSTREAM_PATH, headers=headers, content=body + ) as upstream: + if upstream.status_code != 200: + raw = await upstream.aread() + return ( + web.Response( + status=upstream.status_code, + body=raw, + content_type="application/json", + ), + None, + ) + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + ) + await response.prepare(request) + try: + async for chunk in upstream.aiter_raw(): + if chunk: + reader.feed(chunk) + await response.write(chunk) + await response.write_eof() + except _CLIENT_GONE as exc: + logger.debug("codex client disconnected during stream: %s", exc) + return response, reader.response + except httpx.HTTPError as exc: + return ( + _error_response(502, f"UiPath LLM Gateway is unreachable: {exc}"), + None, + ) + + +# --- helpers -------------------------------------------------------------- + + +async def _read_body(request: web.Request) -> bytes | None: + raw = await request.read() + if not raw.startswith(GZIP_MAGIC): + return raw + try: + return gzip.decompress(raw) + except (OSError, EOFError): + return None + + +def _error_response(status: int, message: str) -> web.Response: + return web.json_response( + {"error": {"message": message, "type": "uipath_codex_gateway_error"}}, + status=status, + ) + + +__all__ = [ + "CLIENT_MAX_SIZE", + "MCP_PATH", + "CodexGatewayProxy", + "MountedHandler", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/upstream.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/upstream.py new file mode 100644 index 00000000..a9ea4e3a --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/gateway/upstream.py @@ -0,0 +1,90 @@ +"""Upstream HTTP clients for the UiPath LLM Gateway. + +``UiPathHttpxAsyncClient`` already supplies endpoint selection, the refreshing +auth pipeline, org and tenant routing headers, retries and the UiPath SSL +defaults, so the shim configures one per model and adds nothing. + +Codex speaks the OpenAI Responses API, and the tenant's OpenAI models advertise +the ``OpenAiResponses`` flavor, so every upstream client is a passthrough +OpenAI/responses client. The gateway reads the model from the URL path and +selects responses-vs-chat from the ``x-uipath-llmgateway-apiflavor`` header. +""" + +from __future__ import annotations + +import asyncio +import logging + +import httpx +from uipath.llm_client import UiPathHttpxAsyncClient +from uipath.llm_client.settings import ( + ApiType, + RoutingMode, + UiPathAPIConfig, + UiPathBaseSettings, +) +from uipath.llm_client.settings.constants import ApiFlavor, VendorType + +logger = logging.getLogger(__name__) + +# The full gateway endpoint (incl. model + org/tenant) is baked into the +# frozen base_url, so the request path is just "/". +UPSTREAM_PATH = "/" +UPSTREAM_TIMEOUT = httpx.Timeout(timeout=900.0, connect=30.0) +API_VERSION = "2025-04-01-preview" + + +def build_api_config() -> UiPathAPIConfig: + """Passthrough routing config for an OpenAI Responses-API call.""" + return UiPathAPIConfig( + api_type=ApiType.COMPLETIONS, + routing_mode=RoutingMode.PASSTHROUGH, + vendor_type=VendorType.OPENAI, + api_flavor=ApiFlavor.RESPONSES, + api_version=API_VERSION, + freeze_base_url=True, + ) + + +class UpstreamRegistry: + """Lazily built, per-model upstream clients with a single shutdown point.""" + + def __init__(self, settings: UiPathBaseSettings) -> None: + self._settings = settings + self._clients: dict[str, UiPathHttpxAsyncClient] = {} + self._lock = asyncio.Lock() + + async def get(self, model: str) -> UiPathHttpxAsyncClient: + """Return the client for a model, building it on first use.""" + client = self._clients.get(model) + if client is not None: + return client + async with self._lock: + client = self._clients.get(model) + if client is None: + client = UiPathHttpxAsyncClient( + model_name=model, + client_settings=self._settings, + api_config=build_api_config(), + timeout=UPSTREAM_TIMEOUT, + logger=logger, + ) + self._clients[model] = client + logger.debug( + "upstream client ready for %s at %s", model, client.base_url + ) + return client + + async def aclose(self) -> None: + """Close every client this registry created.""" + async with self._lock: + clients = list(self._clients.values()) + self._clients.clear() + for client in clients: + try: + await client.aclose() + except Exception: + logger.debug("upstream client close failed", exc_info=True) + + +__all__ = ["UPSTREAM_PATH", "UPSTREAM_TIMEOUT", "UpstreamRegistry", "build_api_config"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/loader.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/loader.py new file mode 100644 index 00000000..24a6d46a --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/loader.py @@ -0,0 +1,143 @@ +"""Dynamic loader for UiPathCodexAgent definitions (e.g. 'main.py:agent').""" + +from __future__ import annotations + +import hashlib +import importlib.util +import inspect +import logging +import os +import sys +from pathlib import Path +from typing import Any, Self + +from uipath.runtime.errors import UiPathErrorCategory + +from ..agent import UiPathCodexAgent +from .errors import UiPathCodexErrorCode, UiPathCodexRuntimeError + +logger = logging.getLogger(__name__) + + +class CodexAgentLoader: + """Load a UiPathCodexAgent from a 'file_path:variable_name' string.""" + + def __init__(self, name: str, file_path: str, variable_name: str): + self.name = name + self.file_path = file_path + self.variable_name = variable_name + self._context_manager: Any = None + + @classmethod + def from_path_string(cls, name: str, file_path: str) -> Self: + if ":" not in file_path: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_INVALID, + "Invalid agent path format", + f"Invalid path format '{file_path}'. Expected 'file_path:variable_name'.", + UiPathErrorCategory.USER, + ) + file, variable = file_path.split(":", 1) + return cls(name=name, file_path=file, variable_name=variable) + + async def load(self) -> UiPathCodexAgent: + cwd = os.path.abspath(os.getcwd()) + abs_file_path = os.path.abspath(os.path.normpath(self.file_path)) + + if not Path(abs_file_path).is_relative_to(Path(cwd)): + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_LOAD_FAILURE, + "Invalid agent file path", + f"Agent file path '{self.file_path}' must be within the working directory.", + UiPathErrorCategory.USER, + ) + if not os.path.exists(abs_file_path): + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_NOT_FOUND, + "Agent file not found", + f"Agent file '{self.file_path}' does not exist.", + UiPathErrorCategory.USER, + ) + + self._setup_python_path(cwd) + module = self._import_module(abs_file_path) + + agent_object = getattr(module, self.variable_name, None) + if agent_object is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_NOT_FOUND, + "Agent variable not found", + f"'{self.variable_name}' not found in module '{self.file_path}'.", + UiPathErrorCategory.USER, + ) + + agent = await self._resolve_agent(agent_object) + + if not isinstance(agent, UiPathCodexAgent): + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_TYPE_ERROR, + "Invalid agent type", + f"Expected uipath_codex_sdk.UiPathCodexAgent, got '{type(agent).__name__}'.", + UiPathErrorCategory.USER, + ) + return agent + + def _setup_python_path(self, cwd: str) -> None: + if cwd not in sys.path: + sys.path.insert(0, cwd) + src_dir = os.path.join(cwd, "src") + if os.path.isdir(src_dir) and src_dir not in sys.path: + sys.path.insert(0, src_dir) + + def _import_module(self, abs_file_path: str) -> Any: + digest = hashlib.sha256(abs_file_path.encode()).hexdigest()[:12] + module_name = f"{Path(abs_file_path).stem}_{digest}" + spec = importlib.util.spec_from_file_location(module_name, abs_file_path) + if not spec or not spec.loader: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_IMPORT_ERROR, + "Failed to load agent module", + f"Could not load module from: {abs_file_path}", + UiPathErrorCategory.USER, + ) + try: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + except Exception as e: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.AGENT_LOAD_FAILURE, + "Failed to execute agent module", + f"Error loading module from {abs_file_path}: {e}", + UiPathErrorCategory.USER, + ) from e + + async def _resolve_agent(self, agent_object: Any) -> Any: + """Resolve direct instances, (async) factory callables, and async context managers.""" + if callable(agent_object) and not isinstance(agent_object, UiPathCodexAgent): + if inspect.iscoroutinefunction(agent_object): + agent_instance = await agent_object() + else: + agent_instance = agent_object() + else: + agent_instance = agent_object + + if hasattr(agent_instance, "__aenter__") and callable( + agent_instance.__aenter__ + ): + self._context_manager = agent_instance + return await agent_instance.__aenter__() + return agent_instance + + async def cleanup(self) -> None: + if self._context_manager: + try: + await self._context_manager.__aexit__(None, None, None) + except Exception as e: + logger.warning("Error during agent cleanup: %s", e) + finally: + self._context_manager = None + + +__all__ = ["CodexAgentLoader"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/mocking.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/mocking.py new file mode 100644 index 00000000..e6cddb45 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/mocking.py @@ -0,0 +1,195 @@ +"""Eval tool mocking: which calls the active mocker answers, and with what. + +``uipath eval`` (and ``uipath run`` / ``uipath debug`` with a +``simulation.json``) wraps the runtime in ``UiPathMockRuntime``, which puts a +mocking context and a mocker in ContextVars. The tool host snapshots that +context when the run starts, so the checks below see it. + +* :func:`is_simulated` tells whether the active mocker will answer a mock + name, with the mockers' own match rules (exact for the LLM and mockito + mockers; exact or ``_`` → space for simulated components). The public + ``is_tool_simulated`` normalizes ``_`` for every strategy, which would let + ``Get_User`` through while the LLM mocker refuses it. +* :func:`mock_spec_of` is what the mocker is told about a tool: its mock name + (LangChain's ``resource.name``; see :func:`mock_name_of`), description, + schemas and example calls. A built-in tool defines ``mock_spec`` itself; a + custom tool gets one from its attributes. +* :func:`wrap` is ``uipath.eval.mocks.mockable`` with a spec. + +A simulated durable call is answered in the same turn by the tool host: no +job, task or extraction starts and the run does not suspend. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +#: The span attribute ``mockable`` sets on the current span of a mocked call. +MOCKED_ANNOTATION_KEY = "__uipath_response_mocked" +#: Prefix of a mocked child span's name (low-code's). +SIMULATED_PREFIX = "Simulated result: " + +#: Output schema of a tool that declares none. +ANY_OBJECT_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}} + + +@dataclass(frozen=True) +class MockSpec: + """What the mocker is told about a tool (``mockable``'s parameters).""" + + name: str + description: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] + example_calls: list[Any] = field(default_factory=list) + + +@dataclass +class MockSpan: + """The child span a mocked durable call runs under (``start_mock_span``). + + ``output_attribute`` names the attribute the result goes into and + ``output_keys``, when set, keeps only those keys of a dict result (the + same as the span's ``hold_span`` on a real call). + """ + + span: Any + output_attribute: str = "output" + output_keys: tuple[str, ...] | None = None + + +def mock_name_of(tool: Any) -> str: + """The name a tool is mocked by: ``mock_name``, else the display name, else ``name``. + + From an ``agent.json`` resource that is ``resource.name`` (the factories + set ``mock_name`` where the display name differs); for a tool declared in + code, the name passed to the factory. Escalations lowercase their + model-facing name in their own ``mock_spec``. + """ + return str( + getattr(tool, "mock_name", None) + or getattr(tool, "display_name", None) + or tool.name + ) + + +def mock_spec_of(tool: Any) -> MockSpec: + """``tool.mock_spec`` when it has one, else a spec from its attributes.""" + spec = getattr(tool, "mock_spec", None) + if isinstance(spec, MockSpec): + return spec + output = getattr(tool, "output_json_schema", None) + examples = getattr(tool, "example_calls", None) + return MockSpec( + name=mock_name_of(tool), + description=str(getattr(tool, "description", "") or ""), + input_schema=_input_schema(tool), + output_schema=output + if isinstance(output, dict) and output + else ANY_OBJECT_SCHEMA, + example_calls=list(examples) if isinstance(examples, (list, tuple)) else [], + ) + + +def _input_schema(tool: Any) -> dict[str, Any]: + try: + schema = tool.input_schema + except Exception: + return dict(ANY_OBJECT_SCHEMA) + return schema if isinstance(schema, dict) else dict(ANY_OBJECT_SCHEMA) + + +def is_simulated(name: str) -> bool: + """True when the active mocker will answer ``name``. + + The mockers' own match rules: exact for the LLM and mockito strategies, + exact or after replacing ``_`` with a space for simulated components. A + mockito strategy without a behavior for ``name`` is False: the real tool + runs (the mockito mocker would call it itself). + """ + try: + from uipath.eval.mocks._mock_context import mocker_context, mocking_context + from uipath.eval.mocks._types import ( + LLMMockingStrategy, + MockitoMockingStrategy, + ) + except Exception: # pragma: no cover - eval extras missing + return False + ctx, mocker = mocking_context.get(), mocker_context.get() + if ctx is None or mocker is None: + # set_execution_context leaves the mocker None when it cannot build one. + return False + if ctx.components: + spaced = name.replace("_", " ") + return any( + c.component_id == name or c.component_id.replace("_", " ") == spaced + for c in ctx.components + ) + strategy = ctx.strategy + if isinstance(strategy, LLMMockingStrategy): + return name in [t.name for t in strategy.tools_to_simulate] + if isinstance(strategy, MockitoMockingStrategy): + return any(b.function == name for b in strategy.behaviors) + return False + + +def wrap(spec: MockSpec, func: Callable[..., Any]) -> Callable[..., Any]: + """``mockable(...)(func)`` with ``spec``; ``func`` itself without the eval extras.""" + try: + from uipath.eval.mocks import mockable + except Exception: # pragma: no cover - eval extras missing + return func + return mockable( + name=spec.name, + description=spec.description, + input_schema=spec.input_schema, + output_schema=spec.output_schema, + example_calls=spec.example_calls, + )(func) + + +def is_mocked(span: Any) -> bool: + """True when ``mockable`` marked ``span`` as answered by the mocker.""" + return bool((getattr(span, "attributes", None) or {}).get(MOCKED_ANNOTATION_KEY)) + + +def mark_simulated(span: Any) -> bool: + """Rename a mocked child span ``Simulated result: `` (low-code's). + + Returns whether it was mocked. The span keeps its type; the ``Tool call`` + span around it is never renamed. + """ + if not is_mocked(span): + return False + name = str(getattr(span, "name", "") or "") + if not name.startswith(SIMULATED_PREFIX): + span.update_name(f"{SIMULATED_PREFIX}{name}") + return True + + +def mocked_text(value: Any) -> str: + """The model-facing text of a mocked value (a tool without ``mocked_output``).""" + if isinstance(value, str): + return value + return json.dumps(value, default=str) + + +__all__ = [ + "ANY_OBJECT_SCHEMA", + "MOCKED_ANNOTATION_KEY", + "MockSpan", + "MockSpec", + "SIMULATED_PREFIX", + "is_mocked", + "is_simulated", + "mark_simulated", + "mock_name_of", + "mock_spec_of", + "mocked_text", + "wrap", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py new file mode 100644 index 00000000..25634084 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py @@ -0,0 +1,2021 @@ +"""Runtime that owns the Codex run loop and routes it through UiPath. + +For each run the runtime starts a loopback gateway shim, points a bundled +``codex`` subprocess at it via a custom ``model_provider``, starts a thread and +runs the rendered prompt, then maps the Codex ``TurnResult`` to a +``UiPathRuntimeResult``. + +Durable tools (``agent.tools``) add a suspend/resume cycle (immediate tools, +such as Integration Service activities, are served on the same MCP server and +answered right away; they never suspend the run): + +* **suspend** – the shim also serves the tools over MCP (``uipath_tools``). + A call starts its Orchestrator job and is never answered, so the turn stays + blocked. Once nothing else is running, a watcher SIGKILLs the codex + app-server (a graceful stop would record the call as "aborted by user"), + the rollout is saved to the state database, and the run returns SUSPENDED + with one ``WaitJobRaw`` per pending call, keyed by its ``call_id``. +* **resume** – each finished job becomes the text result of its call. Once no + call is pending, the rollout is restored, the thread resumed, the results + injected with ``thread/inject_items`` under the original call ids, and a new + turn asks the model to continue (it may suspend again). + +Tracing (see :mod:`.telemetry`): the ``Agent run`` span is opened by the +factory's wrapper; this runtime adds the ``Tool call`` spans of the items Codex +runs and completes the still-open ``Tool call`` span of each durable call with +its job's result on resume. Model calls are traced in the gateway shim, +durable tool starts in the tool host. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import glob +import json +import logging +import os +import signal +import subprocess +import time +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, cast + +from opentelemetry import context as context_api +from uipath.core.errors import UiPathPendingTriggerError +from uipath.platform.common import ( + WaitDeepRag, + WaitDeepRagRaw, + WaitJob, + WaitTask, +) +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathRuntimeStorageProtocol, + UiPathStreamOptions, +) +from uipath.runtime.errors import UiPathErrorCategory +from uipath.runtime.events import ( + UiPathRuntimeMessageEvent, + UiPathRuntimeStateEvent, + UiPathRuntimeStatePhase, +) +from uipath.runtime.schema import UiPathRuntimeSchema + +from ..agent import UiPathCodexAgent +from ..attachments import ( + Attachment, + AttachmentStore, + attachment_paths, + attachments_block, + model_attachments, + model_has_attachments, + span_attachments, + to_attachment, + values_at, + with_tickets, +) +from ..mcp import ( + McpServer, + RemoteMcpServer, + build_mcp_config_overrides, + build_mcp_env, + missing_bearer_token_env_vars, +) +from ..tool_results import ToolResultStore +from ..tools import ( + UiPathDurableTool, + UiPathDurableToolPending, + UiPathTool, + is_durable_tool, + tool_type_of, +) +from .errors import ( + UiPathCodexErrorCode, + UiPathCodexRuntimeError, + UiPathRunTerminatedError, +) +from .gateway import PROVIDER_ID, CodexGatewayShim +from .schema import get_entrypoint_schema, prompt_fields, to_strict_output_schema +from .session_store import ( + STATE_STARTED, + CodexSessionStore, + DanglingCall, + EntrypointRecord, + PendingCall, + ThreadRecord, + cut_torn_tail, + dangling_calls, + encode_rollout, + find_rollout, + has_call, + parse_rollout, + write_rollout, +) +from .telemetry import ( + TOOL_ITEM_TYPES, + ItemSpans, + complete_tool_call, + end_tool_call, + start_tool_call, +) +from .tool_host import ( + DEFAULT_IMMEDIATE_TIMEOUT_SEC, + SERVER_NAME, + TOKEN_ENV, + DurableToolHost, + input_paths, + output_paths, +) +from .triggers import CodexResumeTriggerHandler + +logger = logging.getLogger(__name__) + +#: ``--config`` overrides that keep codex from calling home (ChatGPT plugins +#: backend) — a robot has no use for it and it can stall start-up. Applied to +#: agents with durable tools (or ``agent.hermetic=True``); other agents run +#: with the codex configuration they always had. +HERMETIC_OVERRIDES: tuple[str, ...] = ( + "features.plugins=false", + 'chatgpt_base_url="http://127.0.0.1:9/backend-api"', +) + + +#: ``UiPathRuntimeResult.metadata`` key carrying ``{call_id: {tool, arguments}}`` +#: of a suspension (see ``factory.UiPathCodexResumableRuntime``). +DURABLE_CALLS_METADATA = "durable_calls" + +#: User message of the turn that follows the injected results. +CONTINUE_PROMPT = "The pending tool call(s) above have completed. Continue the task." +#: Result injected for a sibling call that never ran because the run suspended. +NOT_EXECUTED_OUTPUT = ( + "Not executed: the run was suspended while waiting for another tool. " + "Call it again if still needed." +) +#: Developer instruction added when the agent has durable tools. +DURABLE_INSTRUCTIONS = ( + "Tools marked [long-running] start a UiPath job, a research task or a human " + "review that can take minutes or hours. Call independent ones together in a " + "single response, and never call one again while you wait for its result." +) + +#: Developer instruction added when the agent handles job attachments. +ATTACHMENT_INSTRUCTIONS = ( + "Files are passed around as JobAttachment objects with an ID, a FullName " + "and a MimeType. You cannot open them yourself: to work with a file's " + "contents, pass its JobAttachment to a tool that accepts attachments. Only " + "use attachment IDs from your input or from tool results, never made-up ones." +) +#: Added to :data:`ATTACHMENT_INSTRUCTIONS` when the output has attachment fields. +OUTPUT_ATTACHMENT_INSTRUCTIONS = ( + "To return a file in your final output, set the attachment field to the ID " + "of a JobAttachment from your input or a tool result." +) + +#: Directory (under the runtime dir) used as CODEX_HOME by default. +CODEX_HOME_DIR = "codex_home" +#: Durable calls hold for a day before codex gives up on them. +DURABLE_TOOL_TIMEOUT_SEC = 86400.0 +#: Codex's per-call timeout margin over the host's own immediate-tool timeout. +IMMEDIATE_TOOL_TIMEOUT_MARGIN_SEC = 30.0 +#: How long nothing may happen before the watcher suspends. +QUIET_WINDOW_SEC = 1.0 +#: How often the watcher looks. +WATCH_INTERVAL_SEC = 0.1 +#: How long a tools/call waits for its function_call to reach the rollout. +ROLLOUT_PROBE_TIMEOUT_SEC = 5.0 +ROLLOUT_PROBE_INTERVAL_SEC = 0.05 +#: Pause before re-suspending when a tool's ``finish`` reports the job as still +#: pending although its trigger fired, so the in-process resume loop does not +#: poll Orchestrator in a tight loop. +RESUSPEND_BACKOFF_SEC = 2.0 +#: State-database namespace / key of the input a durable run started with. +#: ``uipath run agent --file input.json --resume`` passes it again; it is +#: recognised as "no resume input" rather than as the result of a job. +RUN_INPUT_NAMESPACE = "codex_run" +RUN_INPUT_KEY = "input" +#: State-database key of the job attachments a suspended run knows. +RUN_ATTACHMENTS_KEY = "attachments" +#: State-database key of the results durable tools keep for each other. +RUN_RESULTS_KEY = "tool_results" +#: How long the watcher waits for the killed app-server to exit. +KILL_WAIT_SEC = 10.0 +#: How long the run waits for the watcher to finish once it asked to suspend. +WATCHER_JOIN_SEC = 30.0 + +#: Graph node ids (see ``schema.get_agent_graph``) that state events name. +AGENT_NODE = "agent" +TOOLS_NODE = "tools" + + +def _node_for(item_type: Any) -> str: + """Graph node a Codex item of ``item_type`` ran on. + + Shares ``telemetry.TOOL_ITEM_TYPES`` so an item that gets a tool span is + the same item the debugger shows on the ``tools`` node. + """ + return TOOLS_NODE if item_type in TOOL_ITEM_TYPES else AGENT_NODE + + +def _mcp_result_text(result: Any) -> Any: + """Pull the text out of an MCP tool result's content list, if present.""" + if isinstance(result, dict): + content = result.get("content") + if isinstance(content, list) and content: + first = content[0] + if isinstance(first, dict) and "text" in first: + return first["text"] + return result + + +def _clean_codex_error(message: str) -> str: + """Codex surfaces upstream failures as a JSON string; extract the message.""" + try: + payload = json.loads(message) + except ValueError: + return message + if isinstance(payload, dict) and isinstance(payload.get("error"), dict): + return str(payload["error"].get("message", message)) + return message + + +def normalize_resume_input(value: Any, pending_ids: list[str]) -> dict[str, Any]: + """Map a resume payload to ``{call_id: resume_value}`` for the pending calls. + + Accepted shapes: + + * ``{call_id: value, ...}`` (a deployed resume, or a local ``--resume`` + with no input): the ids that are pending are taken, the rest ignored; + * ``{}`` or ``None``: nothing has fired (``--resume`` while jobs run); + * any other value: the result of the single pending call (``uipath run + agent '{"output": 42}' --resume``). With several pending calls it is + ambiguous and ignored. + """ + if value is None: + return {} + if isinstance(value, dict): + if not value: + return {} + keyed = {k: v for k, v in value.items() if k in pending_ids} + if keyed: + return keyed + if len(pending_ids) == 1: + return {pending_ids[0]: value} + logger.warning( + "Resume input is not keyed by call id and %d calls are pending; " + "specify which one it answers as {call_id: value}. Pending: %s", + len(pending_ids), + pending_ids, + ) + return {} + + +def _descendants(pid: int) -> list[int]: + """Descendant pids of ``pid`` (Linux ``/proc``; empty elsewhere).""" + if not os.path.isdir("/proc"): + return [] + children: dict[int, list[int]] = {} + for stat in glob.glob("/proc/[0-9]*/stat"): + try: + with open(stat) as f: + rest = f.read().rsplit(")", 1)[1].split() + children.setdefault(int(rest[1]), []).append(int(stat.split("/")[2])) + except (OSError, IndexError, ValueError): + continue + out: list[int] = [] + stack = [pid] + while stack: + for child in children.get(stack.pop(), []): + out.append(child) + stack.append(child) + return out + + +def _kill_pids(pids: list[int]) -> None: + sigkill = getattr(signal, "SIGKILL", signal.SIGTERM) + for pid in pids: + try: + os.kill(pid, sigkill) + except (ProcessLookupError, PermissionError, OSError): + pass + + +# -------------------------------------------------------------------------- +# turn bookkeeping +# -------------------------------------------------------------------------- +class _ItemTracker: + """Started/completed thread items of the running turn.""" + + def __init__(self, durable_names: frozenset[str] = frozenset()) -> None: + self._durable_names = durable_names + self.started: dict[str, dict[str, Any]] = {} + self.completed: dict[str, dict[str, Any]] = {} + self.last_activity = time.monotonic() + + def touch(self) -> None: + self.last_activity = time.monotonic() + + def on_started(self, item: dict[str, Any]) -> None: + self.touch() + if isinstance(item.get("id"), str): + self.started[item["id"]] = item + + def on_completed(self, item: dict[str, Any]) -> None: + self.touch() + if isinstance(item.get("id"), str): + self.completed[item["id"]] = item + + def _is_durable(self, item: dict[str, Any]) -> bool: + """A call to a durable tool (held by the host until the run suspends).""" + return self._is_hosted(item) and item.get("tool") in self._durable_names + + @staticmethod + def _is_hosted(item: dict[str, Any]) -> bool: + """A call served by the tool host, which traces it itself.""" + return item.get("type") == "mcpToolCall" and item.get("server") == SERVER_NAME + + def running_durable(self) -> list[str]: + """Ids of durable (``uipath_tools``) calls that started, not completed.""" + return [ + iid + for iid, item in self.started.items() + if iid not in self.completed and self._is_durable(item) + ] + + def running_non_durable(self) -> list[str]: + """Ids of tool-ish items that started and have not completed.""" + return [ + iid + for iid, item in self.started.items() + if iid not in self.completed + and not self._is_durable(item) + and item.get("type") not in ("userMessage", "agentMessage", "reasoning") + ] + + def sibling_output(self, call_id: str) -> str | None: + """The output a completed sibling call produced, as text, if known.""" + item = self.completed.get(call_id) + if item is None: + return None + itype = item.get("type") + if itype == "commandExecution": + text = item.get("aggregated_output") or "" + code = item.get("exit_code") + return f"Exit code: {code}\nOutput:\n{text}" if code is not None else text + if itype == "mcpToolCall": + if item.get("error"): + return json.dumps(item["error"], default=str) + result = _mcp_result_text(item.get("result")) + return ( + result if isinstance(result, str) else json.dumps(result, default=str) + ) + return json.dumps(item, default=str) + + +@dataclass +class _SuspendState: + requested: bool = False + killed: bool = False + error: Exception | None = None + + +def _raise_fatal(host: DurableToolHost | None, cause: BaseException | None) -> None: + """Raise the error of a tool that failed the run, if one did.""" + if host is not None and host.fatal_error is not None: + raise host.fatal_error from cause + + +def _dumps_fields(fields: dict[str, Any]) -> str: + return json.dumps(fields) if fields else "" + + +def _call_job_key(call: Any) -> str | None: + """Key of the job a saved durable call started, if it recorded one.""" + value = getattr(call, "value", None) + job = value.get("job") if isinstance(value, dict) else None + if not isinstance(job, dict): + return None + return job.get("key") or job.get("Key") + + +def _call_operation(call: Any) -> dict[str, str]: + """What a saved durable call waits on: ``job_key``, ``deep_rag_id`` or ``task_id``.""" + key = _call_job_key(call) + if key: + return {"job_key": key} + value = getattr(call, "value", None) + deep_rag = value.get("deep_rag") if isinstance(value, dict) else None + if isinstance(deep_rag, dict) and deep_rag.get("id"): + return {"deep_rag_id": str(deep_rag["id"])} + extraction = value.get("extraction") if isinstance(value, dict) else None + if isinstance(extraction, dict): + op = extraction.get("operationId") or extraction.get("operation_id") + if op: + return { + "extraction_id": str(op), + "document_id": str( + extraction.get("documentId") or extraction.get("document_id") or "" + ), + } + validation = value.get("extraction_validation") if isinstance(value, dict) else None + if isinstance(validation, dict): + op = validation.get("operationId") or validation.get("operation_id") + if op: + out = {"validation_operation_id": str(op)} + task_url = value.get("task_url") if isinstance(value, dict) else None + if task_url: + out["task_url"] = str(task_url) + return out + batch = value.get("batch_transform") if isinstance(value, dict) else None + if isinstance(batch, dict) and batch.get("id"): + return {"batch_transform_id": str(batch["id"])} + task = value.get("action") if isinstance(value, dict) else None + if isinstance(task, dict) and (task.get("id") is not None or task.get("key")): + out = {"task_id": str(task.get("id"))} if task.get("id") is not None else {} + if task.get("key"): + out["task_key"] = str(task["key"]) + return out + return {} + + +def _resumed_tool_event( + call: Any, result: str, execution_id: str +) -> UiPathRuntimeStateEvent: + """``● tools`` event for a durable call finished on resume. + + Mirrors the shape of a live ``mcpToolCall`` event (server / tool / + arguments / result / status) and adds the job key and ``resumed: True``. + """ + status = "completed" + try: + parsed = json.loads(result) + except (TypeError, ValueError): + parsed = None + if isinstance(parsed, dict) and isinstance(parsed.get("status"), str): + status = parsed["status"].lower() + return UiPathRuntimeStateEvent( + payload={ + "server": SERVER_NAME, + "tool": call.tool, + "arguments": call.args, + **(_call_operation(call) or {"job_key": None}), + "result": parsed if parsed is not None else result, + "status": status, + "resumed": True, + }, + node_name=TOOLS_NODE, + execution_id=execution_id, + phase=UiPathRuntimeStatePhase.UPDATED, + metadata={ + "source": "codex", + "item_type": "durableToolResult", + "call_id": call.call_id, + }, + ) + + +async def _rebuilt_trigger(handler: Any, value: Any) -> Any | None: + """A resume trigger to read a saved suspend value with, or None. + + ``create_trigger`` is side-effect free for a ``WaitJob`` and a + ``WaitTask`` (``WaitEscalation``) only: for a + ``WaitDeepRag`` it registers the DeepRAG run's external trigger with + Orchestrator again, so that trigger is built directly, and so is a + Batch Transform's (``WaitBatchTransformFile``, which carries the + ``destination_path`` its reader needs). + """ + from uipath.platform.common import ( + WaitDocumentExtraction, + WaitDocumentExtractionValidation, + ) + + from ..batch_transform_tools import WaitBatchTransformFile + + if isinstance(value, (WaitDocumentExtraction, WaitDocumentExtractionValidation)): + # IXP operations: no folder (the platform clears it), and the reader + # needs project_id and tag in the payload. + from uipath.core.serialization import serialize_object + from uipath.core.triggers import ( + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, + ) + + is_extraction = isinstance(value, WaitDocumentExtraction) + op: Any = ( + value.extraction + if isinstance(value, WaitDocumentExtraction) + else value.extraction_validation + ) + trigger = UiPathResumeTrigger.model_validate( + { + "triggerType": UiPathResumeTriggerType.IXP_EXTRACTION + if is_extraction + else UiPathResumeTriggerType.IXP_VS_ESCALATION, + "itemKey": op.operation_id, + } + ) + # The names the platform gives them (a validation trigger is "Api"). + trigger.trigger_name = ( + UiPathResumeTriggerName.EXTRACTION + if is_extraction + else UiPathResumeTriggerName.API + ) + payload = serialize_object(value) + payload.setdefault("project_id", op.project_id) + payload.setdefault("tag", op.tag) + trigger.payload = payload + return trigger + if isinstance(value, WaitBatchTransformFile): + from uipath.core.serialization import serialize_object + from uipath.core.triggers import ( + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, + ) + + trigger = UiPathResumeTrigger.model_validate( + { + "triggerType": UiPathResumeTriggerType.BATCH_RAG, + "itemKey": value.batch_transform.id, + "folderKey": value.index_folder_key, + "folderPath": value.index_folder_path, + } + ) + trigger.trigger_name = UiPathResumeTriggerName.BATCH_RAG + trigger.payload = serialize_object(value) + return trigger + if isinstance(value, (WaitJob, WaitTask)): + return await handler.create_trigger(value) + if isinstance(value, WaitDeepRag): + from uipath.core.triggers import ( + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, + ) + + trigger = UiPathResumeTrigger.model_validate( + { + "triggerType": UiPathResumeTriggerType.DEEP_RAG, + "itemKey": value.deep_rag.id, + "folderKey": value.index_folder_key, + "folderPath": value.index_folder_path, + } + ) + # Excluded from the model's aliases: set after validation. + trigger.trigger_name = ( + UiPathResumeTriggerName.DEEP_RAG_RAW + if isinstance(value, WaitDeepRagRaw) + else UiPathResumeTriggerName.DEEP_RAG + ) + trigger.payload = value.model_dump(mode="json") + return trigger + return None + + +@dataclass +class _ResumePlan: + thread_id: str + items: list[dict[str, Any]] = field(default_factory=list) + + +class UiPathCodexRuntime: + """Executes a single :class:`UiPathCodexAgent` via the Codex SDK. + + Args: + agent: The agent definition. + runtime_id: UiPath runtime id (the job key when deployed). + entrypoint: Entrypoint name the agent was loaded for. + file_path: Path of the module the agent was loaded from. + storage: Runtime storage (the state database). Required for durable + tools; the session state lives there. + codex_home: ``CODEX_HOME`` for an agent with durable tools; + ``agent.codex_home`` takes precedence. Defaults to + ``/__uipath/codex_home``. Agents without durable tools + ignore it and keep codex's own ``CODEX_HOME`` resolution. + """ + + def __init__( + self, + *, + agent: UiPathCodexAgent, + runtime_id: str, + entrypoint: str, + file_path: str, + storage: UiPathRuntimeStorageProtocol | None = None, + codex_home: str | None = None, + ) -> None: + self.agent = agent + self.runtime_id = runtime_id + self.entrypoint = entrypoint + self.file_path = file_path + self.storage = storage + self._codex_home = codex_home + self._resumed_events: list[UiPathRuntimeEvent] = [] + #: Durable calls whose saved ``Tool call`` span was completed. + self._completed_spans: set[str] = set() + self._session = ( + CodexSessionStore(storage, runtime_id) if storage is not None else None + ) + self._shim: CodexGatewayShim | None = None + #: The job attachments this run knows (its input's, tools' results). + self._attachments = AttachmentStore() + #: Results durable tools keep for each other (IXP extraction → validation). + self._results = ToolResultStore() + + # --- execution -------------------------------------------------------- + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + # Single code path: drain the stream and return its terminal result. + result: UiPathRuntimeResult | None = None + stream_options = ( + UiPathStreamOptions(**options.model_dump()) if options else None + ) + async for event in self.stream(input, stream_options): + if isinstance(event, UiPathRuntimeResult): + result = event + if result is None: # pragma: no cover - stream always yields a result + return UiPathRuntimeResult( + output={"output": ""}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + return result + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Run a Codex turn, emitting one UiPath event per completed Codex item. + + Each Codex ``ItemCompletedNotification`` (reasoning, command execution, + agent message, ...) becomes a message/state event; the terminal + ``TurnCompletedNotification`` yields the final ``UiPathRuntimeResult``. + With durable tools the terminal result may be SUSPENDED instead. + """ + self._refuse_breakpoints(options) + tools = self._durable_tools() + resume = bool(options is not None and getattr(options, "resume", False)) + plan: _ResumePlan | None = None + if tools and resume: + self._resumed_events = [] + self._attachments = await self._load_attachments() + self._results = await self._load_results() + prepared = await self._prepare_resume(input) + # Show what each finished durable call returned before the run + # continues (or suspends again on the calls still running). + for event in self._resumed_events: + yield event + self._resumed_events = [] + if isinstance(prepared, UiPathRuntimeResult): + yield prepared + return + plan = prepared + prompt = CONTINUE_PROMPT + else: + prompt = self._render_prompt(input or {}) + self._attachments = AttachmentStore(self.input_attachments(input or {})) + self._results = ToolResultStore() + if tools: + session = self._require_session() + await session.clear_all() + await session.set_entrypoint(EntrypointRecord(self.entrypoint)) + await self._save_run_input(input) + await self._save_attachments(force=True) + await self._save_results(force=True) + # A kept state file may still hold triggers of an earlier + # suspension; the resumable wrapper would merge them in. + save_triggers = getattr(self.storage, "save_triggers", None) + if save_triggers is not None: + await save_triggers(self.runtime_id, []) + + if plan is not None: + agent_input = await self._load_run_input() + else: + agent_input = input + async for event in self._run( + prompt, + tools, + plan, + agent_input=agent_input if isinstance(agent_input, dict) else None, + ): + yield event + + @staticmethod + def _refuse_breakpoints(options: UiPathStreamOptions | None) -> None: + """Answer a breakpoint request out loud instead of ignoring it. + + The agent loop runs inside the codex app-server, so this runtime owns + no node it could hold execution at. ``uipath debug`` still offers + stepping and node breakpoints, and a debugger that accepts them and + then runs straight through looks broken rather than unsupported. + """ + if options is None or not options.breakpoints: + return + logger.warning( + "Breakpoints are not supported by the Codex runtime, so this run " + "continues to completion. The agent loop runs inside the codex " + "app-server and exposes no node to hold at. A durable tool (a " + "UiPath process tool) is the supported way to suspend a run." + ) + + async def get_schema(self) -> UiPathRuntimeSchema: + return get_entrypoint_schema(self.agent, self.entrypoint) + + async def dispose(self) -> None: + if self._shim is not None: + await self._shim.stop() + self._shim = None + + # --- the codex run ---------------------------------------------------- + + async def _run( + self, + prompt: str, + tools: dict[str, UiPathDurableTool], + plan: _ResumePlan | None, + agent_input: dict[str, Any] | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + from openai_codex import ApprovalMode, AsyncCodex, CodexConfig, Sandbox + from openai_codex.errors import CodexError + from openai_codex.generated.v2_all import ( + ErrorNotification, + ItemCompletedNotification, + ItemStartedNotification, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, + TurnStatus, + ) + + codex_home = self._codex_home_dir(tools) + # Every durable path below only runs with tools, where it is set. + durable_home = codex_home or "" + host: DurableToolHost | None = None + _, immediate = self._agent_tools() + if tools or immediate: + host = DurableToolHost( + [*tools.values(), *immediate.values()], + store=self._require_session() if tools else None, + runtime_id=self.runtime_id, + context=contextvars.copy_context(), + tag_descriptions=self.agent.durable_tool_instructions, + agent_input=agent_input, + attachments=self._attachments, + model=self.agent.model, + results=self._results, + ) + if plan is not None: + await self._restore_rollout(durable_home, plan.thread_id) + + shim = CodexGatewayShim( + default_model=self.agent.model, + mcp_handler=host, + # The handlers run on aiohttp's tasks: hand them the Agent run span. + trace_context=context_api.get_current(), + ) + await shim.start() + self._shim = shim + item_dicts: list[dict[str, Any]] = [] + tracker = _ItemTracker(frozenset(tools)) + item_spans = ItemSpans() + suspend = _SuspendState() + watcher: asyncio.Task[None] | None = None + fatal_watch: asyncio.Task[None] | None = None + codex: Any = None + thread_id: str | None = None + try: + servers = self._mcp_servers(shim, host) + # Warn about remote MCP servers whose bearer token can't be + # resolved — codex silently skips them (no tools) otherwise. + for name, var in missing_bearer_token_env_vars( + servers, dict(os.environ) + ).items(): + logger.warning( + "MCP server %r: bearer_token_env_var %r is not set and no " + "literal bearer_token was provided; codex will skip this " + "server and its tools will be unavailable.", + name, + var, + ) + codex_bin = self.agent.codex_bin or os.environ.get("UIPATH_CODEX_BIN") + env = {**shim.env(), **build_mcp_env(servers)} + if codex_home is not None: + env["CODEX_HOME"] = codex_home + config = CodexConfig( + codex_bin=codex_bin, + config_overrides=( + *shim.config_overrides(), + *(HERMETIC_OVERRIDES if self._hermetic(tools) else ()), + *build_mcp_config_overrides(servers), + ), + env=env, + cwd=self._project_dir(), + ) + codex = AsyncCodex(config) + await codex.__aenter__() + thread_kwargs: dict[str, Any] = { + "model": self.agent.model, + "model_provider": PROVIDER_ID, + "sandbox": Sandbox(self.agent.sandbox), + "approval_mode": ApprovalMode(self.agent.approval_mode), + "base_instructions": self.agent.base_instructions, + } + instructions = self._developer_instructions(tools) + if instructions: + thread_kwargs["developer_instructions"] = instructions + if plan is not None: + thread = await codex.thread_resume( + plan.thread_id, cwd=self._project_dir(), **thread_kwargs + ) + if thread.id != plan.thread_id: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.SESSION_MISMATCH, + "Resumed the wrong Codex thread", + f"Expected thread {plan.thread_id}, codex resumed {thread.id}.", + UiPathErrorCategory.SYSTEM, + ) + await self._inject_items(codex, thread.id, plan.items) + else: + thread = await codex.thread_start(**thread_kwargs) + thread_id = thread.id + if host is not None and tools: + await self._require_session().set_thread(ThreadRecord(thread.id)) + host.bind(self._rollout_probe(durable_home, thread.id)) + + run_kwargs: dict[str, Any] = {} + if self.agent.output_schema is not None: + run_kwargs["output_schema"] = to_strict_output_schema( + self.agent.output_schema.model_json_schema() + ) + if host is not None: + fatal_watch = asyncio.create_task(self._stop_on_fatal(codex, host)) + try: + handle = await thread.turn(prompt, **run_kwargs) + if host is not None and tools: + watcher = asyncio.create_task( + self._watch( + codex, shim, host, tracker, suspend, durable_home, thread.id + ) + ) + async for notification in handle.stream(): + payload = notification.payload + tracker.touch() + if isinstance(payload, ItemStartedNotification): + item = payload.item.model_dump(mode="json") + tracker.on_started(item) + if not _ItemTracker._is_hosted(item): + item_spans.start(item) + elif isinstance(payload, ItemCompletedNotification): + item = payload.item.model_dump(mode="json") + tracker.on_completed(item) + if not _ItemTracker._is_hosted(item): + item_spans.finish(item) + item_dicts.append(item) + event = self._item_to_event(item) + if event is not None: + yield event + elif isinstance(payload, TurnCompletedNotification): + if payload.turn.status == TurnStatus.failed: + error = payload.turn.error + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.EXECUTION_FAILED, + "Codex execution failed", + _clean_codex_error( + getattr(error, "message", "") or "turn failed" + ), + UiPathErrorCategory.USER, + ) + elif isinstance(payload, ErrorNotification): + # Turn-scoped error. If Codex will retry, log and + # continue; otherwise it's fatal — surface it (it + # doesn't always arrive as a failed turn/completed). + err = getattr(payload, "error", None) + msg = _clean_codex_error( + getattr(err, "message", "") or "codex error" + ) + if getattr(payload, "will_retry", False): + logger.warning("codex error (will retry): %s", msg) + else: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.EXECUTION_FAILED, + "Codex execution failed", + msg, + UiPathErrorCategory.USER, + ) + elif isinstance(payload, ThreadTokenUsageUpdatedNotification): + pass # token usage → tracing milestone + except UiPathCodexRuntimeError as e: + _raise_fatal(host, e) + raise + except CodexError as e: + _raise_fatal(host, e) + # The suspend watcher SIGKILLed the app-server: the stream ends + # with a closed transport. That is the expected path. + if suspend.error is not None: + raise suspend.error from e + if not suspend.requested: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.EXECUTION_FAILED, + "Codex execution failed", + _clean_codex_error(str(e)), + UiPathErrorCategory.USER, + ) from e + except Exception as e: + _raise_fatal(host, e) + if suspend.error is not None: + raise suspend.error from e + if not suspend.requested: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.EXECUTION_FAILED, + "Codex execution failed", + _clean_codex_error(str(e)), + UiPathErrorCategory.USER, + ) from e + logger.debug("codex stream ended after suspend kill: %r", e) + if watcher is not None and suspend.requested: + try: + await asyncio.wait_for( + asyncio.shield(watcher), timeout=WATCHER_JOIN_SEC + ) + except asyncio.TimeoutError as e: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, + "Cannot suspend the Codex run", + "The codex app-server did not stop after it was killed.", + UiPathErrorCategory.SYSTEM, + ) from e + if suspend.error is not None: + raise suspend.error + _raise_fatal(host, None) + finally: + if fatal_watch is not None and not fatal_watch.done(): + fatal_watch.cancel() + item_spans.close_all("suspended" if suspend.killed else "interrupted") + if watcher is not None and not watcher.done(): + watcher.cancel() + try: + await watcher + except (asyncio.CancelledError, Exception): + pass + if codex is not None: + try: + await codex.close() + except Exception: + logger.debug("codex close failed", exc_info=True) + if host is not None: + # Only after codex is gone: a live codex would take the release + # answer as the tool result and keep sampling. + await host.release_all() + # Immediate calls cut off by the suspension: their outcome is + # unknown (recorded for the dangling-call outputs below). + await host.cancel_inflight() + await shim.stop() + self._shim = None + + if suspend.killed and thread_id is not None: + yield await self._finish_suspend(durable_home, thread_id, tracker, host) + return + if tools: + await self._require_session().clear_suspension() + yield UiPathRuntimeResult( + output=self._build_output(self._final_text(item_dicts)), + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + def _mcp_servers( + self, shim: CodexGatewayShim, host: DurableToolHost | None + ) -> dict[str, McpServer]: + servers: dict[str, McpServer] = dict(self.agent.mcp_servers or {}) + if host is None: + return servers + if SERVER_NAME in servers: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_INVALID, + "Reserved MCP server name", + f"The MCP server name {SERVER_NAME!r} is reserved for UiPath tools.", + UiPathErrorCategory.USER, + ) + if host.has_durable: + tool_timeout = DURABLE_TOOL_TIMEOUT_SEC + else: + # The host enforces each immediate tool's own timeout; codex's + # per-server one only has to outlast the longest of them. + _, immediate = self._agent_tools() + tool_timeout = ( + max( + ( + getattr(t, "timeout_sec", None) or DEFAULT_IMMEDIATE_TIMEOUT_SEC + for t in immediate.values() + ), + default=DEFAULT_IMMEDIATE_TIMEOUT_SEC, + ) + + IMMEDIATE_TOOL_TIMEOUT_MARGIN_SEC + ) + servers[SERVER_NAME] = RemoteMcpServer( + url=shim.mcp_url, + bearer_token_env_var=TOKEN_ENV, + bearer_token=host.token, + startup_timeout_sec=30.0, + tool_timeout_sec=tool_timeout, + default_tools_approval_mode="approve", + supports_parallel_tool_calls=True, + # A durable call must be a top-level function_call in the rollout + # (it is held, then resumed by call_id). Keep the tools out of + # tool-search deferral and out of code-mode ``exec`` scripts; + # immediate tools share the server, and so the setting. + omit_tools_from=["deferred", "code_mode"], + ) + return servers + + # --- suspend ------------------------------------------------------------ + + def _rollout_probe(self, codex_home: str, thread_id: str): + async def probe(call_id: str) -> bool: + deadline = time.monotonic() + ROLLOUT_PROBE_TIMEOUT_SEC + while True: + path = find_rollout(codex_home, thread_id) + if path is not None: + try: + data = await asyncio.to_thread(path.read_bytes) + except OSError: + data = b"" + if has_call(parse_rollout(cut_torn_tail(data)), call_id): + return True + if time.monotonic() >= deadline: + return False + await asyncio.sleep(ROLLOUT_PROBE_INTERVAL_SEC) + + return probe + + async def _watch( + self, + codex: Any, + shim: CodexGatewayShim, + host: DurableToolHost, + tracker: _ItemTracker, + suspend: _SuspendState, + codex_home: str, + thread_id: str, + ) -> None: + """Suspend once a durable call is held and the turn has gone quiet. + + Runs beside the notification loop, which cannot end by itself while a + call is held (no notifications arrive, and the tool timeout is a day). + So a watcher failure must not go unnoticed: it is recorded as the run + error and Codex is stopped, which ends the stream. + """ + try: + await self._watch_until_suspended( + codex, shim, host, tracker, suspend, codex_home, thread_id + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("The durable-tool suspend watcher failed") + if suspend.error is None: + suspend.error = UiPathCodexRuntimeError( + UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, + "Cannot suspend the Codex run", + f"The suspend watcher failed: {e}", + UiPathErrorCategory.SYSTEM, + ) + host.close() + await self._force_stop(codex) + + async def _watch_until_suspended( + self, + codex: Any, + shim: CodexGatewayShim, + host: DurableToolHost, + tracker: _ItemTracker, + suspend: _SuspendState, + codex_home: str, + thread_id: str, + ) -> None: + first_held: float | None = None + rollout_size = -1 + rollout_changed = time.monotonic() + while True: + await asyncio.sleep(WATCH_INTERVAL_SEC) + held = host.held + if not held: + first_held = None + continue + now = time.monotonic() + if first_held is None: + first_held = now + try: + path = find_rollout(codex_home, thread_id) + size = path.stat().st_size if path is not None else -1 + except OSError: + size = -1 + if size != rollout_size: + rollout_size, rollout_changed = size, now + if host.starting_count: + continue + last = max( + tracker.last_activity, + host.last_activity, + shim.last_activity, + rollout_changed, + ) + if now - last < QUIET_WINDOW_SEC: + continue + running = tracker.running_non_durable() + # Durable calls codex started whose tools/call has not reached the + # host yet (by count: every arrived call is held, starting or + # being answered with a simulated result). + arriving = len(tracker.running_durable()) > ( + len(held) + host.starting_count + getattr(host, "mocking_count", 0) + ) + # Immediate calls whose work runs on the host (they may reach it + # before their item/started arrives). + hosted = getattr(host, "inflight_count", 0) + busy = shim.inflight > 0 or bool(running) or arriving or hosted > 0 + if busy and now - first_held < self.agent.durable_settle_timeout: + continue + if busy: + logger.warning( + "Suspending after %.0fs although call(s) %s are still " + "running%s; they will be reported as not executed (UiPath " + "tool calls already running: as having an unknown outcome).", + self.agent.durable_settle_timeout, + running or list(getattr(host, "inflight_ids", [])), + " (and a durable call has not arrived)" if arriving else "", + ) + # From here on a late tools/call starts nothing: the suspension + # below would record it as "not executed". + host.close() + proc = self._kill_app_server(codex, suspend) + if proc is None: + # No way to stop it cleanly: fail the run (unblocks the stream). + await codex.close() + return + try: + await asyncio.to_thread(proc.wait, KILL_WAIT_SEC) + except subprocess.TimeoutExpired: + # SIGKILL was sent; a process stuck in the kernel cannot write + # to the rollout any more either. + logger.warning( + "The killed codex app-server (pid %s) has not exited after " + "%.0fs; suspending anyway.", + proc.pid, + KILL_WAIT_SEC, + ) + suspend.killed = True + return + + async def _stop_on_fatal(self, codex: Any, host: DurableToolHost) -> None: + """Stop Codex once a tool failed the run (``host.fatal_error``).""" + await host.fatal.wait() + host.close() + await self._force_stop(codex) + + @staticmethod + async def _force_stop(codex: Any) -> None: + """End the codex stream after a watcher failure (the run fails).""" + sync = getattr(getattr(codex, "_client", None), "_sync", None) + proc = getattr(sync, "_proc", None) + try: + if proc is not None and hasattr(proc, "kill"): + children = _descendants(proc.pid) + proc.kill() + _kill_pids(children) + else: + await codex.close() + except Exception: + logger.debug("could not stop codex after a watcher failure", exc_info=True) + + @staticmethod + def _kill_app_server(codex: Any, suspend: _SuspendState) -> Any: + """SIGKILL the codex app-server — never a graceful stop. + + ``turn/interrupt``, ``close()`` and shutdown all persist the held call + as "aborted by user" plus a ```` marker, which the model + would see on resume. + """ + sync = getattr(getattr(codex, "_client", None), "_sync", None) + proc = getattr(sync, "_proc", None) + if proc is None or not hasattr(proc, "kill"): + suspend.error = UiPathCodexRuntimeError( + UiPathCodexErrorCode.SUSPEND_KILL_UNAVAILABLE, + "Cannot suspend the Codex run", + "The codex app-server process handle is not available in this " + "openai-codex version, so the run cannot be suspended cleanly.", + UiPathErrorCategory.SYSTEM, + ) + return None + children = _descendants(proc.pid) + suspend.requested = True + proc.kill() + # MCP stdio servers and PTY sessions would outlive their parent. + _kill_pids(children) + return proc + + async def _finish_suspend( + self, + codex_home: str, + thread_id: str, + tracker: _ItemTracker, + host: DurableToolHost | None = None, + ) -> UiPathRuntimeResult: + """Save the rollout and dangling calls; return SUSPENDED on the jobs.""" + session = self._require_session() + await self._save_attachments() + await self._save_results() + path = find_rollout(codex_home, thread_id) + if path is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, + "Cannot suspend the Codex run", + f"No rollout file for thread {thread_id} under {codex_home}.", + UiPathErrorCategory.SYSTEM, + ) + raw = await asyncio.to_thread(path.read_bytes) + is_zst = path.name.endswith(".zst") + data = raw if is_zst else cut_torn_tail(raw) + pending = { + cid: call + for cid, call in (await session.get_pending()).items() + if call.state == STATE_STARTED + } + if not pending: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, + "Cannot suspend the Codex run", + "The run was stopped for a durable tool call that has no saved job.", + UiPathErrorCategory.SYSTEM, + ) + dangling: list[DanglingCall] = [] + if not is_zst: + for cid, itype in dangling_calls(parse_rollout(data)): + if cid in pending: + dangling.append(DanglingCall(cid, itype, durable=True)) + continue + output = tracker.sibling_output(cid) + if output is None and host is not None: + # An immediate call answered (or cut off) without its + # item/completed reaching us. + output = host.immediate_outcome(cid) + if output is None: + if cid in tracker.started: + logger.warning( + "Sibling call %s started but its output is unknown; " + "the model is told it was not executed.", + cid, + ) + output = NOT_EXECUTED_OUTPUT + dangling.append(DanglingCall(cid, itype, durable=False, output=output)) + missing = [ + cid for cid in pending if cid not in {d.call_id for d in dangling} + ] + if missing: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, + "Cannot suspend the Codex run", + f"The rollout does not hold the pending call(s) {missing}.", + UiPathErrorCategory.SYSTEM, + ) + rel = os.path.relpath(path, codex_home) + await session.set_rollout(encode_rollout(rel, data)) + await session.set_thread(ThreadRecord(thread_id, rel)) + await session.set_dangling(dangling) + await session.set_completed({}) + logger.info( + "Codex run suspended on %d durable call(s): %s", + len(pending), + list(pending), + ) + return self._suspended_result(pending) + + @staticmethod + def _suspended_result(pending: dict[str, PendingCall]) -> UiPathRuntimeResult: + # Live models, not dicts: a dict would silently become an API trigger. + ordered = sorted(pending.values(), key=lambda c: c.order) + return UiPathRuntimeResult( + output={call.call_id: call.suspend_value() for call in ordered}, + status=UiPathRuntimeStatus.SUSPENDED, + # Read by the factory's resumable wrapper to show a slim summary + # of the suspension instead of the full trigger values. + metadata={ + DURABLE_CALLS_METADATA: { + call.call_id: {"tool": call.tool, "arguments": call.args} + for call in ordered + } + }, + ) + + # --- resume ------------------------------------------------------------- + + async def _prepare_resume(self, input: Any) -> _ResumePlan | UiPathRuntimeResult: + """Finish fired calls; re-suspend on the rest, or plan the injection.""" + session = self._require_session() + thread = await session.get_thread() + rollout = await session.get_rollout() + pending = await session.get_pending() + if thread is None or rollout is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.SESSION_NOT_FOUND, + "Nothing to resume", + "No suspended Codex session was found for this run in the state " + "database.", + UiPathErrorCategory.USER, + ) + record = await session.get_entrypoint() + if record is not None and record.entrypoint != self.entrypoint: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ENTRYPOINT_MISMATCH, + "Resumed on a different entrypoint", + f"The run was suspended on '{record.entrypoint}' and cannot be " + f"resumed on '{self.entrypoint}'.", + UiPathErrorCategory.USER, + ) + pending = {cid: c for cid, c in pending.items() if c.state == STATE_STARTED} + completed = await session.get_completed() + dangling = await session.get_dangling() + if not pending and not completed and not dangling: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.SESSION_NOT_FOUND, + "Nothing to resume", + "The Codex run is not suspended on any durable tool call.", + UiPathErrorCategory.USER, + ) + tools = self._durable_tools() + fired = await self._resume_values(input, pending) + attachments_out: dict[str, list[Attachment]] = {} + done: list[str] = [] + still_running = False + for cid, value in fired.items(): + call = pending[cid] + tool = tools.get(call.tool) + if tool is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.TOOL_NOT_FOUND, + "Durable tool not found", + f"Call {cid} was made to tool '{call.tool}', which the agent no " + "longer declares.", + UiPathErrorCategory.USER, + ) + try: + text = await tool.finish(call.args, value) + except UiPathRunTerminatedError as e: + # The result ends the run: close this call's spans with it, + # keep what finished before it, then fault the run. + self._trace_resumed_call(call, tool, e.result_text) + self._completed_spans.add(cid) + if done: + await session.set_completed(completed) + await session.remove_pending(done) + raise + except UiPathDurableToolPending as e: + logger.info("durable call %s is still pending: %s", cid, e) + still_running = True + continue + except Exception as e: + logger.exception( + "durable tool %s failed to finish call %s", call.tool, cid + ) + text = json.dumps({"status": "error", "error": str(e)}) + key = getattr(tool, "result_key", None) + if isinstance(key, str): + self._results.clear_pending(key) + else: + harvested = self._harvest(tool, text) + if harvested: + attachments_out[cid] = harvested + self._store_result(tool, call.args, value) + completed[cid] = text + done.append(cid) + self._resumed_events.append( + _resumed_tool_event(call, text, self.runtime_id) + ) + self._trace_resumed_call(call, tool, text, attachments_out.get(cid)) + self._completed_spans.add(cid) + # Before the calls are marked done: a crash in between would lose the + # results of calls that never run again. + await self._save_results() + if done: + await session.set_completed(completed) + await session.remove_pending(done) + await self._save_attachments() + remaining = {cid: c for cid, c in pending.items() if cid not in done} + if remaining: + if still_running: + await asyncio.sleep(RESUSPEND_BACKOFF_SEC) + logger.info("Codex run still waiting on %s", list(remaining)) + return self._suspended_result(remaining) + + items: list[dict[str, Any]] = [] + if dangling: + for d in dangling: + output = completed.get(d.call_id) if d.durable else d.output + items.append( + { + "type": d.output_type, + "call_id": d.call_id, + "output": output if output is not None else NOT_EXECUTED_OUTPUT, + } + ) + else: + items = [ + {"type": "function_call_output", "call_id": cid, "output": text} + for cid, text in completed.items() + ] + return _ResumePlan(thread_id=thread.thread_id, items=items) + + async def _resume_values( + self, input: Any, pending: dict[str, PendingCall] + ) -> dict[str, Any]: + """``{call_id: resume_value}`` of the pending calls that can finish now. + + An input keyed by pending call ids, or a bare value for the only + pending call, is an explicit answer (:func:`normalize_resume_input`). + ``None`` means the resumable wrapper already read the stored triggers. + + An input that answers no call (``{}``, the run's own agent input as + passed again by ``uipath run --file input.json --resume``, or an + unkeyed value while several calls are pending) is not an answer, but + the wrapper took it as one and deleted the trigger of a single pending + call. Read those calls' jobs here, so the run does not use the input + as a job result, nor re-suspend only for the wrapper's fired-trigger + loop to find the job finished in a second cycle. + """ + ordered = sorted(pending.values(), key=lambda c: c.order) + ids = [c.call_id for c in ordered] + if input is None or ( + isinstance(input, dict) and any(k in pending for k in input) + ): + return normalize_resume_input(input, ids) + if input == {}: + # Also what the wrapper passes when no stored trigger fired: the + # calls that still have one were just read. + return await self._read_pending_jobs(ordered, stored_too=False) + if len(ids) == 1 and input != await self._load_run_input(): + return normalize_resume_input(input, ids) + logger.info( + "Resume input answers no pending call (%s); reading their jobs", ids + ) + return await self._read_pending_jobs(ordered, stored_too=True) + + async def _read_pending_jobs( + self, calls: list[PendingCall], *, stored_too: bool + ) -> dict[str, Any]: + """Read the triggers of ``calls``; return the values of fired ones. + + A call whose stored trigger was deleted gets one rebuilt from its saved + suspend value (:func:`_rebuilt_trigger`): only for a ``WaitJob``, a + ``WaitTask`` or a ``WaitDeepRag``, as building a trigger from other values may start + work (``InvokeProcess`` starts a job). ``stored_too`` also reads calls whose trigger is still stored. + The stored triggers of fired calls are deleted, as the wrapper does, + so a later suspension does not carry them over and hand their job to + another call. + """ + get_triggers = getattr(self.storage, "get_triggers", None) + stored: dict[str, list[Any]] = {} + if get_triggers is not None: + for trigger in await get_triggers(self.runtime_id) or []: + stored.setdefault(trigger.interrupt_id, []).append(trigger) + handler = CodexResumeTriggerHandler() + fired: dict[str, Any] = {} + used: list[Any] = [] + for call in calls: + triggers = stored.get(call.call_id) + if triggers and not stored_too: + continue + try: + if not triggers: + trigger = await _rebuilt_trigger(handler, call.suspend_value()) + if trigger is None: + continue + triggers = [trigger] + fired[call.call_id] = await handler.read_trigger(triggers[0]) + except UiPathPendingTriggerError: + continue + except Exception: + logger.warning( + "Could not read the result of durable call %s", + call.call_id, + exc_info=True, + ) + continue + used.extend(stored.get(call.call_id) or []) + delete_triggers = getattr(self.storage, "delete_triggers", None) + if used and delete_triggers is not None: + await delete_triggers(self.runtime_id, used) + return fired + + async def _save_run_input(self, input: Any) -> None: + """Remember the agent input of a fresh run (see ``RUN_INPUT_KEY``).""" + if self.storage is None: + return + try: + await self.storage.set_value( + self.runtime_id, RUN_INPUT_NAMESPACE, RUN_INPUT_KEY, {"value": input} + ) + except Exception: # not storable: a resume then never matches it + logger.debug("Could not save the run input", exc_info=True) + + async def _load_run_input(self) -> Any: + if self.storage is None: + return None + raw = await self.storage.get_value( + self.runtime_id, RUN_INPUT_NAMESPACE, RUN_INPUT_KEY + ) + return raw.get("value") if isinstance(raw, dict) else None + + async def _save_attachments(self, *, force: bool = False) -> None: + """Remember the known attachments for a resume (durable runs only).""" + if self.storage is None or not (force or self._attachments.changed): + return + try: + await self.storage.set_value( + self.runtime_id, + RUN_INPUT_NAMESPACE, + RUN_ATTACHMENTS_KEY, + {"value": self._attachments.to_json()}, + ) + self._attachments.changed = False + except Exception: + logger.warning("Could not save the run's job attachments", exc_info=True) + + async def _load_attachments(self) -> AttachmentStore: + """The attachments a suspended run knew: its input's, then the saved ones.""" + store = AttachmentStore() + run_input = await self._load_run_input() + if isinstance(run_input, dict): + for attachment in self.input_attachments(run_input): + store.register(attachment) + if self.storage is not None: + raw = await self.storage.get_value( + self.runtime_id, RUN_INPUT_NAMESPACE, RUN_ATTACHMENTS_KEY + ) + saved = raw.get("value") if isinstance(raw, dict) else None + for attachment in AttachmentStore.from_json(saved).attachments: + store.register(attachment) + store.changed = False + return store + + def _store_result(self, tool: Any, args: Any, resume_value: Any) -> None: + """Keep the result a durable tool offers (``stored_result``).""" + hook = getattr(tool, "stored_result", None) + if not callable(hook): + return + try: + stored = hook(args, resume_value) + except Exception: + logger.warning("Could not keep the result of %s", tool.name, exc_info=True) + return + if stored is not None: + key, value = stored + self._results.put(key, value) + + async def _save_results(self, *, force: bool = False) -> None: + if self.storage is None or not (force or self._results.changed): + return + try: + await self.storage.set_value( + self.runtime_id, + RUN_INPUT_NAMESPACE, + RUN_RESULTS_KEY, + {"value": self._results.to_json()}, + ) + self._results.changed = False + except Exception: + logger.warning("Could not save the run's tool results", exc_info=True) + + async def _load_results(self) -> ToolResultStore: + if self.storage is None: + return ToolResultStore() + raw = await self.storage.get_value( + self.runtime_id, RUN_INPUT_NAMESPACE, RUN_RESULTS_KEY + ) + return ToolResultStore.from_json( + raw.get("value") if isinstance(raw, dict) else None + ) + + def _harvest(self, tool: Any, text: str) -> list[Attachment]: + """Register the attachments a durable tool's result carries.""" + paths = output_paths(tool) + if not paths: + return [] + try: + data = json.loads(text) + except ValueError: + return [] + return self._attachments.harvest(paths, data) + + # --- job attachments -------------------------------------------------- + + def input_attachments(self, input: dict[str, Any]) -> list[Attachment]: + """The attachments of a run input (none when it does not validate).""" + if self.agent.input_schema is None or not isinstance(input, dict): + return [] + try: + return model_attachments(self.agent.input_schema(**input)) + except Exception: + return [] + + def output_attachments(self, output: Any) -> list[Attachment]: + """The attachments of the run output (as built by ``_build_output``).""" + if self.agent.output_schema is None: + return [] + paths = attachment_paths(self.agent.output_schema.model_json_schema()) + found = (to_attachment(v) for v in values_at(output, paths)) + return [a for a in found if a is not None] + + def _uses_attachments(self) -> bool: + if model_has_attachments(self.agent.input_schema) or model_has_attachments( + self.agent.output_schema + ): + return True + durable, immediate = self._agent_tools() + return any(input_paths(t) for t in [*durable.values(), *immediate.values()]) + + def _developer_instructions(self, tools: dict[str, UiPathDurableTool]) -> str: + """The thread's developer instructions (the same on start and resume).""" + parts: list[str] = [] + if tools and self.agent.durable_tool_instructions: + parts.append(DURABLE_INSTRUCTIONS) + if self._uses_attachments(): + text = ATTACHMENT_INSTRUCTIONS + if model_has_attachments(self.agent.output_schema): + text += " " + OUTPUT_ATTACHMENT_INSTRUCTIONS + parts.append(text) + return "\n\n".join(parts) + + @staticmethod + def _trace_resumed_call( + call: PendingCall, + tool: UiPathDurableTool, + text: str, + attachments: list[Attachment] | None = None, + ) -> None: + """Complete the call's ``Tool call`` span with the job's result. + + The span was left open when the job started (the job hangs under it). + A call saved without one (before the job was known to have started, + or by an older version) gets a short ``resumed`` span instead. + """ + try: + result: Any = json.loads(text) + except ValueError: + result = text + status = result.get("status") if isinstance(result, dict) else None + error = ( + f"{'job' if _call_job_key(call) else 'call'} {status}" + if status in ("faulted", "error", "failed") + else None + ) + out = span_attachments(attachments, "out") if attachments else None + if call.span is not None: + complete_tool_call(call.span, output=result, error=error, attachments=out) + return + span = start_tool_call( + call.tool, + tool_type=tool_type_of(tool, getattr(tool, "kind", "durable")), + call_id=call.call_id, + arguments=call.args, + resumed=True, + job_key=_call_job_key(call), + deep_rag_id=_call_operation(call).get("deep_rag_id"), + batch_transform_id=_call_operation(call).get("batch_transform_id"), + extraction_id=_call_operation(call).get("extraction_id"), + validation_operation_id=_call_operation(call).get( + "validation_operation_id" + ), + task_id=_call_operation(call).get("task_id"), + attachments=out, + ) + end_tool_call(span, output=result, error=error) + + async def fail_open_tool_spans(self, error: str) -> None: + """Complete every still-open durable ``Tool call`` span with ``error``. + + Called by the factory's wrapper when the run faults: no later + execution will use those jobs' results. + """ + if self._session is None or not self.agent.tools: + return + for call in (await self._session.get_pending()).values(): + if call.span is None: + continue + if call.call_id not in self._completed_spans: + complete_tool_call(call.span, error=error) + call.span = None + await self._session.put_pending(call) + + async def _restore_rollout(self, codex_home: str, thread_id: str) -> None: + rollout = await self._require_session().get_rollout() + if rollout is None: # pragma: no cover - checked in _prepare_resume + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.SESSION_NOT_FOUND, + "Nothing to resume", + "The suspended Codex rollout is missing from the state database.", + UiPathErrorCategory.USER, + ) + target = await asyncio.to_thread(write_rollout, codex_home, thread_id, rollout) + logger.debug("restored codex rollout to %s", target) + + @staticmethod + async def _inject_items( + codex: Any, thread_id: str, items: list[dict[str, Any]] + ) -> None: + """Pair the results with their calls via raw ``thread/inject_items``. + + The typed SDK has no ``inject_items``; ``turn/start`` tool output would + drop the call id. A wrong call id is silently dropped by codex, and no + injection at all makes it write "aborted". + """ + if not items: + return + sync = getattr(getattr(codex, "_client", None), "_sync", None) + request_raw = getattr(sync, "_request_raw", None) + if request_raw is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.EXECUTION_FAILED, + "Cannot resume the Codex run", + "thread/inject_items is not reachable in this openai-codex version.", + UiPathErrorCategory.SYSTEM, + ) + await asyncio.to_thread( + request_raw, "thread/inject_items", {"threadId": thread_id, "items": items} + ) + + # --- helpers ---------------------------------------------------------- + + def _durable_tools(self) -> dict[str, UiPathDurableTool]: + return self._agent_tools()[0] + + def _agent_tools( + self, + ) -> tuple[dict[str, UiPathDurableTool], dict[str, UiPathTool]]: + """``(durable, immediate)`` tools of the agent, by name.""" + durable: dict[str, UiPathDurableTool] = {} + immediate: dict[str, UiPathTool] = {} + for tool in self.agent.tools or []: + name = getattr(tool, "name", None) + if name in durable or name in immediate: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_INVALID, + "Duplicate tool name", + f"Two tools are named '{name}'.", + UiPathErrorCategory.USER, + ) + is_durable = is_durable_tool(tool) + is_immediate = isinstance(tool, UiPathTool) + if is_durable == is_immediate: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_INVALID, + "Invalid tool", + f"Tool {name!r} must implement either UiPathDurableTool " + "(start/finish) or UiPathTool (call)" + + (", not both." if is_durable else "."), + UiPathErrorCategory.USER, + ) + if is_durable: + durable[tool.name] = cast(UiPathDurableTool, tool) + else: + immediate[tool.name] = cast(UiPathTool, tool) + return durable, immediate + + def _require_session(self) -> CodexSessionStore: + if self._session is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_INVALID, + "No state storage", + "Durable tools need the runtime state storage; create the runtime " + "through UiPathCodexRuntimeFactory.", + UiPathErrorCategory.SYSTEM, + ) + return self._session + + def _codex_home_dir(self, tools: dict[str, UiPathDurableTool]) -> str | None: + """``CODEX_HOME`` to run codex with, or None to leave it as it was. + + Durable tools need a run-scoped home (the rollout is saved from and + restored into it). Agents without them keep codex's own resolution + (``$CODEX_HOME`` or ``~/.codex``) unless ``agent.codex_home`` is set. + """ + if self.agent.codex_home: + path = self.agent.codex_home + elif tools: + path = self._codex_home or os.path.join( + self._project_dir(), "__uipath", CODEX_HOME_DIR + ) + else: + return None + path = os.path.abspath(path) + os.makedirs(path, exist_ok=True) + return path + + def _hermetic(self, tools: dict[str, UiPathDurableTool]) -> bool: + if self.agent.hermetic is not None: + return self.agent.hermetic + return bool(tools) + + def _project_dir(self) -> str: + return os.path.dirname(os.path.abspath(self.file_path)) or os.getcwd() + + def _render_prompt(self, input: dict[str, Any]) -> str: + """The first user message; no side effects (the factory calls it too). + + An attachment renders as a JobAttachment JSON object. Attachments the + prompt does not show (a template without their field, or a plain + ``prompt``/``message`` input) are listed in a block after it. + """ + fields = input + model: Any = None + attachment_fields: set[str] = set() + if self.agent.input_schema is not None: + try: + model = self.agent.input_schema(**input) + fields, attachment_fields = with_tickets(model, model.model_dump()) + except Exception as e: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.INPUT_INVALID, + "Invalid input", + f"Input does not match the agent input schema: {e}", + UiPathErrorCategory.USER, + ) from e + + text = self._format_prompt(fields) + if not attachment_fields: + return text + if self.agent.prompt is not None: + hidden_fields = attachment_fields - set(prompt_fields(self.agent.prompt)) + elif text == _dumps_fields(fields): + hidden_fields = set() # the whole input is the prompt + else: + hidden_fields = attachment_fields + hidden = [ + a + for name in sorted(hidden_fields) + for a in model_attachments(getattr(model, name, None)) + ] + if not hidden: + return text + block = attachments_block(hidden) + return f"{text}\n\n{block}" if text else block + + def _format_prompt(self, fields: dict[str, Any]) -> str: + if self.agent.prompt is not None: + try: + return self.agent.prompt.format(**fields) + except KeyError as e: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.INPUT_INVALID, + "Missing prompt field", + f"Prompt template references {e} which is not in the input.", + UiPathErrorCategory.USER, + ) from e + except (AttributeError, IndexError, TypeError) as e: + # ``{order[id]}`` given a string, ``{order.city}`` given a dict. + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.INPUT_INVALID, + "Invalid prompt field", + f"Input does not fit the prompt template: {e}", + UiPathErrorCategory.USER, + ) from e + + for key in ("prompt", "message", "input", "query"): + value = fields.get(key) + if isinstance(value, str) and value: + return value + return _dumps_fields(fields) + + def _item_to_event(self, item: dict[str, Any]) -> UiPathRuntimeEvent | None: + """Map a completed Codex thread item to a UiPath runtime event. + + State events name the graph node the item ran on (``agent`` or + ``tools``), so a debugger can match them against the schema graph. + The console debugger prints only that name as the header (``● tools``); + the payload keys it prints below (``command`` / ``server`` + ``tool``) + and ``metadata["item_type"]`` still tell a shell command from a tool. + """ + itype = item.get("type") + if itype == "userMessage": + return None # don't echo the input back as an event + if itype == "agentMessage": + return UiPathRuntimeMessageEvent( + payload={"text": item.get("text", ""), "phase": item.get("phase")}, + metadata={"source": "codex", "item_type": itype}, + execution_id=self.runtime_id, + ) + if itype == "reasoning": + # A reasoning item has `summary: list[str]` and `content: list[str]` + # (no `text`). Prefer the summary, fall back to full content. + parts = item.get("summary") or item.get("content") or [] + if isinstance(parts, list): + text = "\n".join(p for p in parts if isinstance(p, str)) + else: + text = str(parts) + if not text: + return ( + None # models often emit empty reasoning summaries; skip the noise + ) + return UiPathRuntimeStateEvent( + payload={"text": text}, + node_name=AGENT_NODE, + execution_id=self.runtime_id, + phase=UiPathRuntimeStatePhase.UPDATED, + metadata={"source": "codex", "item_type": itype}, + ) + if itype == "commandExecution": + return UiPathRuntimeStateEvent( + payload={ + "command": item.get("command"), + "output": item.get("aggregated_output"), + "status": item.get("status"), + }, + node_name=TOOLS_NODE, + execution_id=self.runtime_id, + phase=UiPathRuntimeStatePhase.UPDATED, + metadata={"source": "codex", "item_type": itype}, + ) + if itype == "mcpToolCall": + return UiPathRuntimeStateEvent( + payload={ + "server": item.get("server"), + "tool": item.get("tool"), + "arguments": item.get("arguments"), + "result": _mcp_result_text(item.get("result")), + "status": item.get("status"), + "error": item.get("error"), + }, + node_name=TOOLS_NODE, + execution_id=self.runtime_id, + phase=UiPathRuntimeStatePhase.UPDATED, + metadata={"source": "codex", "item_type": itype}, + ) + # fileChange / webSearch / others → generic state event + return UiPathRuntimeStateEvent( + payload=item, + node_name=_node_for(itype), + execution_id=self.runtime_id, + phase=UiPathRuntimeStatePhase.UPDATED, + metadata={"source": "codex", "item_type": itype}, + ) + + def _final_text(self, items: list[dict[str, Any]]) -> str: + """Final assistant text, matching the SDK's selection. + + Mirrors ``_final_assistant_response_from_items``: prefer the + ``final_answer`` agent message; otherwise fall back to the last + phase-less agent message — never a ``commentary`` message. + """ + fallback: str | None = None + for item in reversed(items): + if item.get("type") != "agentMessage": + continue + phase = item.get("phase") + if phase == "final_answer": + return item.get("text") or "" + if phase is None and fallback is None: + fallback = item.get("text") or "" + return fallback or "" + + def _build_output(self, final_text: str) -> dict[str, Any]: + """The run output: ``output_schema``'s fields, else ``{"output": text}``. + + The model writes an attachment field as ``{"ID": ...}``; it must name + an attachment the run knows, which fills in the rest. An unknown ID + fails the run. + """ + schema = self.agent.output_schema + if schema is not None: + paths = attachment_paths(schema.model_json_schema()) + data: Any = None + if paths: + try: + data = json.loads(final_text) + except ValueError: + data = None + if isinstance(data, dict): + data, errors = self._attachments.resolve(paths, data) + if errors: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.OUTPUT_INVALID, + "Invalid output attachment", + "The agent output names a job attachment the run does not " + "know: " + " ".join(errors), + UiPathErrorCategory.USER, + ) + try: + parsed = ( + schema.model_validate(data) + if isinstance(data, dict) + else schema.model_validate_json(final_text) + ) + return parsed.model_dump(mode="json", by_alias=True) + except Exception: + logger.debug("output did not match output_schema; returning raw text") + return {"output": final_text} + + +__all__ = ["UiPathCodexRuntime", "normalize_resume_input"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py new file mode 100644 index 00000000..a3f5779b --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py @@ -0,0 +1,321 @@ +"""Schema derivation for Codex agents (entrypoint input/output JSON schema).""" + +from __future__ import annotations + +import copy +import string +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel +from uipath.runtime.schema import ( + UiPathRuntimeEdge, + UiPathRuntimeGraph, + UiPathRuntimeNode, + UiPathRuntimeSchema, + transform_attachments, + transform_nullable_types, + transform_references, +) + +from ..agent import UiPathCodexAgent +from ..attachments import ATTACHMENT_TITLE, JOB_ATTACHMENT_DEFINITION, RESOURCE_KIND + +#: What the model writes for an attachment field of the agent output: the ID +#: of an attachment the run knows (the runtime fills in the rest). +_OUTPUT_ATTACHMENT: dict[str, Any] = { + "type": "object", + "properties": { + "ID": { + "type": "string", + "description": ( + "ID of a JobAttachment from the input or a tool result; " + "never a made-up one." + ), + } + }, +} + + +def _is_attachment_node(node: dict[str, Any]) -> bool: + ref = node.get("$ref") + if isinstance(ref, str) and ref.rsplit("/", 1)[-1] == JOB_ATTACHMENT_DEFINITION: + return True + return ( + node.get("title") == ATTACHMENT_TITLE + or node.get("x-uipath-resource-kind") == RESOURCE_KIND + ) + + +def _output_attachments(node: Any) -> Any: + """``node`` with each attachment schema replaced by :data:`_OUTPUT_ATTACHMENT`.""" + if isinstance(node, dict): + if _is_attachment_node(node): + return copy.deepcopy(_OUTPUT_ATTACHMENT) + return {k: _output_attachments(v) for k, v in node.items()} + if isinstance(node, list): + return [_output_attachments(v) for v in node] + return node + + +def to_strict_output_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Make a JSON schema satisfy OpenAI Responses structured-output strict mode. + + OpenAI/Codex ``response_format`` (``text.format``) requires every object to + set ``additionalProperties: false`` and list all of its properties in + ``required``. Pydantic's ``model_json_schema()`` does neither, so we walk + the schema (including ``$defs`` and combinators) and enforce it. + + A job attachment (``UiPathAttachment``) becomes ``{"ID"}`` only: the + model names an attachment the run knows, it never writes one. A ``$ref`` + with other keywords (a described model field) moves into an ``anyOf``. + """ + schema = _output_attachments(copy.deepcopy(schema)) + + def walk(node: Any) -> None: + if not isinstance(node, dict): + return + if "$ref" in node and len(node) > 1: + # Strict mode refuses keywords next to ``$ref`` (pydantic puts a + # field's ``description`` there): keep them on a one-branch anyOf. + node["anyOf"] = [{"$ref": node.pop("$ref")}] + props = node.get("properties") + if isinstance(props, dict): + node["additionalProperties"] = False + node["required"] = list(props.keys()) + for value in props.values(): + walk(value) + for defs_key in ("$defs", "definitions"): + defs = node.get(defs_key) + if isinstance(defs, dict): + for value in defs.values(): + walk(value) + items = node.get("items") + if items is not None: + walk(items) + for combinator in ("anyOf", "allOf", "oneOf"): + branches = node.get(combinator) + if isinstance(branches, list): + for value in branches: + walk(value) + + walk(schema) + return schema + + +_PERMISSIVE_INPUT: dict[str, Any] = { + "type": "object", + "properties": {}, + "additionalProperties": True, +} +_DEFAULT_OUTPUT: dict[str, Any] = { + "type": "object", + "properties": {"output": {"type": "string"}}, + "required": ["output"], +} + + +def _model_to_platform_schema(model_type: type[BaseModel]) -> dict[str, Any]: + """JSON schema for the platform: resolve $ref/$defs and collapse nullable anyOf. + + Matches the convention used by the other UiPath integrations + (uipath-claude-sdk / uipath-llamaindex). A ``UiPathAttachment`` field + becomes ``$ref: #/definitions/job-attachment`` (the ``JobAttachment`` + resource kind), as in LangChain. + """ + raw = model_type.model_json_schema() + unpacked, _ = transform_references(raw) + result: dict[str, Any] = { + "type": "object", + "properties": transform_nullable_types(unpacked.get("properties", {})), + "required": unpacked.get("required", []), + } + for key in ("title", "description"): + if key in unpacked: + result[key] = unpacked[key] + return transform_attachments(result) + + +def _parse_prompt_fields(prompt: str) -> dict[str, bool]: + """Named ``str.format`` fields of ``prompt`` mapped to "used bare only". + + A name is bare when every use is a plain ``{name}``; attribute/index + access (``{order.id}``, ``{items[0]}``) marks it as structured. Positional + (``{}``, ``{0}``) fields are skipped, and a template ``str.format`` + cannot parse yields no fields. + """ + fields: dict[str, bool] = {} + + def collect(template: str) -> None: + for _, field_name, format_spec, _ in string.Formatter().parse(template): + if field_name: + base = field_name.split(".", 1)[0].split("[", 1)[0] + if base and not base.isdigit(): + bare = base == field_name + fields[base] = fields.get(base, True) and bare + if format_spec: + collect(format_spec) + + try: + collect(prompt) + except ValueError: + return {} + return fields + + +def prompt_fields(prompt: str) -> list[str]: + """Named ``str.format`` fields of ``prompt``, in order of first use. + + Attribute/index access (``{order.id}``, ``{items[0]}``) contributes its + base name; positional (``{}``, ``{0}``) fields are skipped, and so is a + template ``str.format`` cannot parse. + """ + return list(_parse_prompt_fields(prompt)) + + +def _prompt_input_schema(prompt: str) -> dict[str, Any] | None: + """Input schema derived from a prompt template's placeholders, if any. + + Bare ``{name}`` fields are strings. A name used with attribute/index + access is left untyped: a string value would make ``str.format`` fail, + and the shape it needs cannot be read off the template. + """ + fields = _parse_prompt_fields(prompt) + if not fields: + return None + return { + "type": "object", + "properties": { + name: {"type": "string"} if bare else {} for name, bare in fields.items() + }, + "required": list(fields), + } + + +def _input_schema(agent: UiPathCodexAgent) -> dict[str, Any]: + if agent.input_schema is not None: + return _model_to_platform_schema(agent.input_schema) + if agent.prompt: + derived = _prompt_input_schema(agent.prompt) + if derived is not None: + return derived + return dict(_PERMISSIVE_INPUT) + + +def _output_schema(agent: UiPathCodexAgent) -> dict[str, Any]: + if agent.output_schema is not None: + return _model_to_platform_schema(agent.output_schema) + return dict(_DEFAULT_OUTPUT) + + +#: Codex built-in tools that exist in every sandbox mode: ``shell`` runs +#: (read-only) commands even under ``"read-only"``. +_ALWAYS_BUILTIN_TOOLS = ("shell",) +#: Built-in tools that only do anything when the sandbox allows writes. +_WRITE_BUILTIN_TOOLS = ("apply_patch",) + + +def _builtin_tools(agent: UiPathCodexAgent) -> list[str]: + """Codex built-in tools the agent's sandbox makes usable. + + Codex always exposes ``shell`` (a ``"read-only"`` sandbox still runs + read-only commands, and those calls surface as ``tools`` state events), + so it is always listed; ``apply_patch`` is listed only when the sandbox + allows writes. + """ + tools = list(_ALWAYS_BUILTIN_TOOLS) + if agent.sandbox != "read-only": + tools.extend(_WRITE_BUILTIN_TOOLS) + return tools + + +def get_agent_graph(agent: UiPathCodexAgent) -> UiPathRuntimeGraph: + """Build a visualization graph for a Codex agent. + + The agent is a single model node; durable tools, MCP servers and Codex + built-ins are aggregated into one ``tools`` node, mirroring + uipath-claude-sdk. Node ids are fixed (``agent``/``tools``) because the + runtime's state events reference them by id. + """ + nodes: list[UiPathRuntimeNode] = [ + UiPathRuntimeNode( + id="__start__", name="__start__", type="__start__", subgraph=None + ), + UiPathRuntimeNode( + id="agent", + name=agent.name or "agent", + type="model", + subgraph=None, + metadata={"model_name": agent.model}, + ), + ] + edges: list[UiPathRuntimeEdge] = [ + UiPathRuntimeEdge(source="__start__", target="agent", label="input"), + ] + + uipath_tools = [tool.name for tool in agent.tools or []] + server_names = list(agent.mcp_servers or {}) + builtin_tools = _builtin_tools(agent) + tool_names = [*uipath_tools, *server_names, *builtin_tools] + + if tool_names: + nodes.append( + UiPathRuntimeNode( + id="tools", + name="tools", + type="tool", + subgraph=None, + metadata={ + "tool_names": tool_names, + "tool_count": len(tool_names), + "mcp_servers": server_names, + "builtin_tools": builtin_tools, + }, + ) + ) + edges.append(UiPathRuntimeEdge(source="agent", target="tools", label=None)) + edges.append(UiPathRuntimeEdge(source="tools", target="agent", label=None)) + + nodes.append( + UiPathRuntimeNode(id="__end__", name="__end__", type="__end__", subgraph=None) + ) + edges.append(UiPathRuntimeEdge(source="agent", target="__end__", label="output")) + return UiPathRuntimeGraph(nodes=nodes, edges=edges) + + +def get_entrypoint_schema( + agent: UiPathCodexAgent, entrypoint: str +) -> UiPathRuntimeSchema: + """Build the UiPath runtime schema for a Codex agent entrypoint. + + As in the other integrations, ``filePath`` is the entrypoint name (a + deployed job passes it back to ``uipath run``, so it must be the + ``codex.json`` key, not a path on the packing machine) and ``uniqueId`` + is a fresh UUID (Orchestrator rejects a package whose entry point id is + not a GUID). + + ``metadata.settings.model`` is what ``uipath eval``/``run``/``debug`` + read as the agent model (LLM mocking, input generation and + ``same-as-agent`` judges default to it). Those calls go through the + gateway's chat-completions path rather than the Responses path Codex + uses; the default Codex model (``gpt-5.6-terra``) was checked to answer + there, structured output included. An agent on a Responses-only model + should set an explicit model on its mocking strategies and judges. + """ + return UiPathRuntimeSchema( + filePath=entrypoint, + uniqueId=str(uuid4()), + type="agent", + input=_input_schema(agent), + output=_output_schema(agent), + graph=get_agent_graph(agent), + metadata={"settings": {"model": agent.model}}, + ) + + +__all__ = [ + "get_agent_graph", + "get_entrypoint_schema", + "prompt_fields", + "to_strict_output_schema", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py new file mode 100644 index 00000000..58687f2f --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py @@ -0,0 +1,592 @@ +"""Persistence of Codex session state across a durable-tool suspension. + +Everything lands in the runtime key-value table of the state database, which +is the one thing the platform carries from a suspended job to its resume +(possibly on another robot). That includes the Codex rollout file itself: the +resumed job gets a fresh runtime directory, so ``CODEX_HOME`` is gone and the +thread could not be resumed from it. + +Keys (namespace ``codex_sdk``): + +* ``thread`` – the thread id and where its rollout lived under ``CODEX_HOME``; +* ``rollout`` – the latest rollout snapshot (zlib + base64); +* ``pending`` – durable calls still waiting on their job, by call id; +* ``completed`` – tool result text of calls whose job has finished; +* ``dangling`` – every call left without an output when Codex was stopped, in + rollout order, with its output type and (for siblings) the output to inject; +* ``entrypoint`` – the entrypoint the run started on; +* ``trace`` – the job's ``Agent run`` span, left open while the run is + suspended (see :func:`.telemetry.span_record`). +""" + +from __future__ import annotations + +import asyncio +import base64 +import glob +import json +import logging +import os +import zlib +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from uipath.core.serialization import serialize_object +from uipath.runtime import UiPathRuntimeStorageProtocol + +logger = logging.getLogger(__name__) + +_NAMESPACE = "codex_sdk" +_THREAD_KEY = "thread" +_ROLLOUT_KEY = "rollout" +_PENDING_KEY = "pending" +_COMPLETED_KEY = "completed" +_DANGLING_KEY = "dangling" +_ENTRYPOINT_KEY = "entrypoint" +_TRACE_KEY = "trace" + +#: A compressed rollout above this size is stored, with a warning. +ROLLOUT_WARN_BYTES = 8 * 1024 * 1024 + +_ENC_ZLIB = "zlib+b64" +_ENC_RAW = "raw+b64" + +#: Rollout ``response_item`` types that open a tool call, and the output type +#: that answers each one. +CALL_OUTPUT_TYPES: dict[str, str] = { + "function_call": "function_call_output", + "custom_tool_call": "custom_tool_call_output", + "local_shell_call": "function_call_output", +} +_OUTPUT_TYPES = frozenset( + {"function_call_output", "custom_tool_call_output", "mcp_tool_call_output"} +) + +STATE_STARTING = "starting" +STATE_STARTED = "started" + + +# -------------------------------------------------------------------------- +# records +# -------------------------------------------------------------------------- +@dataclass(frozen=True) +class ThreadRecord: + """The Codex thread a run belongs to. + + Attributes: + thread_id: Codex thread id. + rollout_rel: Path of its rollout relative to ``CODEX_HOME``. + """ + + thread_id: str + rollout_rel: str | None = None + + def to_stored(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_stored(cls, raw: Any) -> ThreadRecord | None: + if not isinstance(raw, dict) or not raw.get("thread_id"): + return None + rel = raw.get("rollout_rel") + return cls( + thread_id=str(raw["thread_id"]), rollout_rel=str(rel) if rel else None + ) + + +@dataclass(frozen=True) +class EntrypointRecord: + """The entrypoint a suspended run started on.""" + + entrypoint: str + + def to_stored(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_stored(cls, raw: Any) -> EntrypointRecord | None: + if not isinstance(raw, dict) or "entrypoint" not in raw: + return None + return cls(entrypoint=str(raw["entrypoint"])) + + +@dataclass +class PendingCall: + """A durable tool call whose job has been (or is being) started. + + Attributes: + call_id: Responses ``call_id``; also the resume trigger interrupt id. + tool: Model-facing tool name. + args: Validated arguments the job was started with. + state: ``"starting"`` (intent recorded, job not yet known) or + ``"started"``. + value_type: Class name of the suspend value, so it can be rebuilt as + a typed model (a plain dict would become an API trigger). + value: The serialized suspend value. + order: Arrival order among the run's durable calls. + span: The call's ``Tool call`` span, left open until the job result + is used (see :func:`.telemetry.span_record`). + """ + + call_id: str + tool: str + args: dict[str, Any] + state: str = STATE_STARTING + value_type: str | None = None + value: Any = None + order: int = 0 + span: dict[str, Any] | None = None + + def to_stored(self) -> dict[str, Any]: + return { + "call_id": self.call_id, + "tool": self.tool, + "args": self.args, + "state": self.state, + "value_type": self.value_type, + "value": serialize_object(self.value) if self.value is not None else None, + "order": self.order, + "span": self.span, + } + + @classmethod + def from_stored(cls, raw: Any) -> PendingCall | None: + if not isinstance(raw, dict): + return None + try: + return cls( + call_id=str(raw["call_id"]), + tool=str(raw["tool"]), + args=dict(raw.get("args") or {}), + state=str(raw.get("state") or STATE_STARTING), + value_type=raw.get("value_type"), + value=raw.get("value"), + order=int(raw.get("order") or 0), + span=raw["span"] if isinstance(raw.get("span"), dict) else None, + ) + except (KeyError, TypeError, ValueError) as e: + logger.warning("Ignoring an unusable pending call record: %s", e) + return None + + def suspend_value(self) -> Any: + """The suspend value as a live model of its original type.""" + return rebuild_suspend_value(self.value_type, self.value) + + +@dataclass +class DanglingCall: + """A tool call the rollout holds without an output. + + Attributes: + call_id: Responses ``call_id``. + item_type: Rollout item type (``function_call``, ``custom_tool_call``...). + durable: Whether it is one of our held durable calls. + output: Text to inject for a non-durable sibling (its real output, or + a "not executed" note). ``None`` for durable calls. + """ + + call_id: str + item_type: str + durable: bool = False + output: str | None = None + + @property + def output_type(self) -> str: + return CALL_OUTPUT_TYPES.get(self.item_type, "function_call_output") + + def to_stored(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_stored(cls, raw: Any) -> DanglingCall | None: + if not isinstance(raw, dict) or not raw.get("call_id"): + return None + return cls( + call_id=str(raw["call_id"]), + item_type=str(raw.get("item_type") or "function_call"), + durable=bool(raw.get("durable")), + output=raw.get("output"), + ) + + +@dataclass +class RolloutRecord: + """A captured rollout file.""" + + rel_path: str + encoding: str + data: str + raw_size: int = 0 + stored_size: int = 0 + + def to_stored(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_stored(cls, raw: Any) -> RolloutRecord | None: + if not isinstance(raw, dict): + return None + try: + return cls( + rel_path=str(raw["rel_path"]), + encoding=str(raw["encoding"]), + data=str(raw["data"]), + raw_size=int(raw.get("raw_size") or 0), + stored_size=int(raw.get("stored_size") or 0), + ) + except (KeyError, TypeError, ValueError) as e: + logger.warning("Ignoring an unusable rollout record: %s", e) + return None + + def content(self) -> bytes: + blob = base64.b64decode(self.data) + if self.encoding == _ENC_ZLIB: + return zlib.decompress(blob) + return blob + + +# -------------------------------------------------------------------------- +# suspend value types +# -------------------------------------------------------------------------- +def _suspend_models() -> dict[str, type]: + from uipath.platform.common import ( + InvokeProcess, + InvokeProcessRaw, + WaitBatchTransform, + WaitDeepRag, + WaitDeepRagRaw, + WaitDocumentExtraction, + WaitDocumentExtractionValidation, + WaitEscalation, + WaitJob, + WaitJobRaw, + WaitTask, + ) + + from ..batch_transform_tools import WaitBatchTransformFile + + return { + m.__name__: m + for m in ( + WaitBatchTransformFile, + WaitBatchTransform, + WaitDocumentExtraction, + WaitDocumentExtractionValidation, + WaitJobRaw, + WaitJob, + InvokeProcessRaw, + InvokeProcess, + WaitDeepRagRaw, + WaitDeepRag, + WaitEscalation, + WaitTask, + ) + } + + +def rebuild_suspend_value(value_type: Any, value: Any) -> Any: + """Rebuild a stored suspend value as a typed model. + + ``UiPathResumeTriggerCreator`` picks the trigger from the value's type; a + plain dict would silently become an API trigger nobody ever fires. + """ + if value is None or not isinstance(value_type, str): + return value + model = _suspend_models().get(value_type) + if model is None: + logger.warning( + "Unknown suspend value type %s; reloading as stored.", value_type + ) + return value + if isinstance(value, model): + return value + return model.model_validate(value) # type: ignore[attr-defined] + + +# -------------------------------------------------------------------------- +# rollout files +# -------------------------------------------------------------------------- +#: File names Codex writes a rollout under. +ROLLOUT_SUFFIXES: tuple[str, ...] = (".jsonl", ".jsonl.zst") +#: Suffix of the temp file a restore is written to before it is moved in place. +RESTORE_TMP_SUFFIX = ".restore-tmp" + + +def find_rollouts(codex_home: str | os.PathLike[str], thread_id: str) -> list[Path]: + """Every rollout file of ``thread_id`` under ``codex_home/sessions``. + + Only real rollout names count (``.jsonl`` / ``.jsonl.zst``): a leftover + restore temp file must never be taken for the live rollout. + """ + pattern = os.path.join( + os.fspath(codex_home), "sessions", "**", f"rollout-*{thread_id}.jsonl*" + ) + return sorted( + Path(p) + for p in glob.glob(pattern, recursive=True) + if p.endswith(ROLLOUT_SUFFIXES) + ) + + +def find_rollout(codex_home: str | os.PathLike[str], thread_id: str) -> Path | None: + """The newest rollout of ``thread_id`` (the one Codex would pick).""" + found = find_rollouts(codex_home, thread_id) + if not found: + return None + return max(found, key=lambda p: p.stat().st_mtime) + + +def cut_torn_tail(data: bytes) -> bytes: + """Drop a partially written last line (a SIGKILL can land mid-write).""" + if not data or data.endswith(b"\n"): + return data + cut = data.rfind(b"\n") + dropped = len(data) - (cut + 1) + logger.warning("Dropping a torn rollout tail of %d bytes.", dropped) + return data[: cut + 1] if cut >= 0 else b"" + + +def parse_rollout(data: bytes | str) -> list[dict[str, Any]]: + """Parse rollout JSON lines; unparsable lines are skipped.""" + text = data.decode("utf-8", errors="replace") if isinstance(data, bytes) else data + out: list[dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + out.append(parsed) + return out + + +def response_items(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + ln.get("payload") or {} for ln in lines if ln.get("type") == "response_item" + ] + + +def has_call(lines: list[dict[str, Any]], call_id: str) -> bool: + """Whether the rollout already holds the call item for ``call_id``.""" + return any( + item.get("type") in CALL_OUTPUT_TYPES and item.get("call_id") == call_id + for item in response_items(lines) + ) + + +def dangling_calls(lines: list[dict[str, Any]]) -> list[tuple[str, str]]: + """``(call_id, item_type)`` of every call without an output, in order.""" + calls: list[tuple[str, str]] = [] + answered: set[str] = set() + for item in response_items(lines): + itype = item.get("type") + cid = item.get("call_id") + if not isinstance(cid, str): + continue + if itype in CALL_OUTPUT_TYPES: + calls.append((cid, str(itype))) + elif itype in _OUTPUT_TYPES: + answered.add(cid) + seen: set[str] = set() + out: list[tuple[str, str]] = [] + for cid, itype in calls: + if cid in answered or cid in seen: + continue + seen.add(cid) + out.append((cid, itype)) + return out + + +def encode_rollout(rel_path: str, data: bytes) -> RolloutRecord: + """Compress a rollout for the KV store (``.zst`` files are stored raw).""" + if rel_path.endswith(".zst"): + blob, encoding = data, _ENC_RAW + else: + blob, encoding = zlib.compress(data, 6), _ENC_ZLIB + encoded = base64.b64encode(blob).decode("ascii") + if len(blob) > ROLLOUT_WARN_BYTES: + logger.warning( + "The Codex rollout is %d bytes compressed; the state database grows " + "with every suspension of a long conversation.", + len(blob), + ) + return RolloutRecord( + rel_path=rel_path, + encoding=encoding, + data=encoded, + raw_size=len(data), + stored_size=len(blob), + ) + + +def write_rollout( + codex_home: str | os.PathLike[str], thread_id: str, record: RolloutRecord +) -> Path: + """Put a stored rollout back under ``codex_home``. + + An existing copy of the thread's rollout (fast-path resume in the same + process, or a re-mounted runtime dir) is replaced in place; otherwise it + goes back to its original relative path. There is never a second copy: + Codex resolves the thread to the newest matching file. + """ + content = record.content() + for leftover in Path(codex_home, "sessions").glob(f"**/*{RESTORE_TMP_SUFFIX}"): + try: + leftover.unlink() + except OSError: + logger.warning("Could not remove a leftover restore file %s", leftover) + existing = find_rollouts(codex_home, thread_id) + target = existing[0] if existing else Path(codex_home) / record.rel_path + for extra in existing[1:]: + try: + extra.unlink() + except OSError: + logger.warning("Could not remove a stale rollout copy %s", extra) + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_name(target.name + RESTORE_TMP_SUFFIX) + tmp.write_bytes(content) + os.replace(tmp, target) + return target + + +# -------------------------------------------------------------------------- +# store +# -------------------------------------------------------------------------- +class CodexSessionStore: + """Codex session state of one runtime id, kept in the state database.""" + + def __init__(self, storage: UiPathRuntimeStorageProtocol, runtime_id: str) -> None: + self._storage = storage + self._runtime_id = runtime_id + self._pending_lock = asyncio.Lock() + + async def _get(self, key: str) -> Any: + return await self._storage.get_value(self._runtime_id, _NAMESPACE, key) + + async def _set(self, key: str, value: Any) -> None: + await self._storage.set_value(self._runtime_id, _NAMESPACE, key, value) + + # thread / entrypoint --------------------------------------------------- + async def get_thread(self) -> ThreadRecord | None: + return ThreadRecord.from_stored(await self._get(_THREAD_KEY)) + + async def set_thread(self, record: ThreadRecord) -> None: + await self._set(_THREAD_KEY, record.to_stored()) + + async def get_entrypoint(self) -> EntrypointRecord | None: + return EntrypointRecord.from_stored(await self._get(_ENTRYPOINT_KEY)) + + async def set_entrypoint(self, record: EntrypointRecord) -> None: + await self._set(_ENTRYPOINT_KEY, record.to_stored()) + + # trace ----------------------------------------------------------------- + async def get_trace(self) -> dict[str, Any] | None: + raw = await self._get(_TRACE_KEY) + return raw if isinstance(raw, dict) and raw.get("span_id") else None + + async def set_trace(self, record: dict[str, Any] | None) -> None: + await self._set(_TRACE_KEY, record) + + # rollout --------------------------------------------------------------- + async def get_rollout(self) -> RolloutRecord | None: + return RolloutRecord.from_stored(await self._get(_ROLLOUT_KEY)) + + async def set_rollout(self, record: RolloutRecord) -> None: + await self._set(_ROLLOUT_KEY, record.to_stored()) + + # pending --------------------------------------------------------------- + async def get_pending(self) -> dict[str, PendingCall]: + raw = await self._get(_PENDING_KEY) + out: dict[str, PendingCall] = {} + if isinstance(raw, dict): + for cid, value in raw.items(): + call = PendingCall.from_stored(value) + if call is not None: + out[str(cid)] = call + return out + + async def _write_pending(self, pending: dict[str, PendingCall]) -> None: + await self._set( + _PENDING_KEY, + {cid: call.to_stored() for cid, call in pending.items()} or None, + ) + + async def put_pending(self, call: PendingCall) -> None: + """Insert or replace one pending call (safe under concurrent calls).""" + async with self._pending_lock: + pending = await self.get_pending() + pending[call.call_id] = call + await self._write_pending(pending) + + async def remove_pending(self, call_ids: list[str]) -> None: + async with self._pending_lock: + pending = await self.get_pending() + for cid in call_ids: + pending.pop(cid, None) + await self._write_pending(pending) + + # completed / dangling ---------------------------------------------------- + async def get_completed(self) -> dict[str, str]: + raw = await self._get(_COMPLETED_KEY) + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items()} + + async def set_completed(self, completed: dict[str, str]) -> None: + await self._set(_COMPLETED_KEY, dict(completed) or None) + + async def get_dangling(self) -> list[DanglingCall]: + raw = await self._get(_DANGLING_KEY) + items = raw.get("calls") if isinstance(raw, dict) else None + out: list[DanglingCall] = [] + for value in items or []: + call = DanglingCall.from_stored(value) + if call is not None: + out.append(call) + return out + + async def set_dangling(self, calls: list[DanglingCall]) -> None: + await self._set( + _DANGLING_KEY, {"calls": [c.to_stored() for c in calls]} if calls else None + ) + + async def clear_suspension(self) -> None: + """Forget everything about the last suspension (not the thread).""" + async with self._pending_lock: + await self._set(_PENDING_KEY, None) + await self._set(_COMPLETED_KEY, None) + await self._set(_DANGLING_KEY, None) + + async def clear_all(self) -> None: + """Start over: a fresh (non-resume) run of the same runtime id.""" + await self.clear_suspension() + await self._set(_THREAD_KEY, None) + await self._set(_ROLLOUT_KEY, None) + await self._set(_ENTRYPOINT_KEY, None) + await self._set(_TRACE_KEY, None) + + +__all__ = [ + "CALL_OUTPUT_TYPES", + "CodexSessionStore", + "DanglingCall", + "EntrypointRecord", + "PendingCall", + "RolloutRecord", + "STATE_STARTED", + "STATE_STARTING", + "ThreadRecord", + "cut_torn_tail", + "dangling_calls", + "encode_rollout", + "find_rollout", + "find_rollouts", + "has_call", + "parse_rollout", + "rebuild_suspend_value", + "write_rollout", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/storage.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/storage.py new file mode 100644 index 00000000..a58534a1 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/storage.py @@ -0,0 +1,241 @@ +"""SQLite implementation of ``UiPathResumableStorageProtocol``. + +A port of uipath-langchain's ``SqliteResumableStorage`` onto a plain +``aiosqlite`` connection (Codex has no LangGraph checkpointer to share). The +schema and serialization are kept identical on purpose: + +* triggers carry an ``interrupt_id`` column and are deleted by it, never by + their serialized JSON; +* ``trigger_name`` and ``payload`` are ``exclude=True`` on + ``UiPathResumeTrigger``, so they are added back before saving. Without them + a reloaded JOB_RAW trigger would come back as a plain JOB trigger, and + ``read_trigger`` would raise on a faulted job instead of returning it. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any, cast + +import aiosqlite +from pydantic import BaseModel +from uipath.core.serialization import serialize_json +from uipath.core.triggers import UiPathResumeTrigger + +_TRIGGERS_TABLE = "__uipath_resume_triggers" +_KV_TABLE = "__uipath_runtime_kv" + + +class SqliteResumableStorage: + """SQLite storage for resume triggers and arbitrary key-value pairs. + + Args: + db_path: Path of the state database (``__uipath/state.db`` by default). + """ + + def __init__(self, db_path: str) -> None: + self.db_path = db_path + self.rs_table_name = _TRIGGERS_TABLE + self.kv_table_name = _KV_TABLE + self._conn: aiosqlite.Connection | None = None + self._lock = asyncio.Lock() + + async def _ensure_conn(self) -> aiosqlite.Connection: + """Open the connection and create the tables on first use.""" + if self._conn is not None: + return self._conn + parent = os.path.dirname(os.path.abspath(self.db_path)) + os.makedirs(parent, exist_ok=True) + conn = await aiosqlite.connect(self.db_path) + try: + await conn.execute("PRAGMA journal_mode=WAL") + await conn.execute("PRAGMA busy_timeout=30000") + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.rs_table_name} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + runtime_id TEXT NOT NULL, + interrupt_id TEXT NOT NULL, + data TEXT NOT NULL, + timestamp DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now', 'utc')) + ) + """ + ) + await conn.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.rs_table_name}_runtime_id + ON {self.rs_table_name}(runtime_id) + """ + ) + await conn.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.kv_table_name} ( + runtime_id TEXT NOT NULL, + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + timestamp DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now', 'utc')), + PRIMARY KEY (runtime_id, namespace, key) + ) + """ + ) + await conn.commit() + except BaseException: + await conn.close() + raise + self._conn = conn + return conn + + # --- resume triggers -------------------------------------------------- + + async def save_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + """Replace every stored trigger of ``runtime_id`` with ``triggers``.""" + async with self._lock: + conn = await self._ensure_conn() + await conn.execute( + f"DELETE FROM {self.rs_table_name} WHERE runtime_id = ?", + (runtime_id,), + ) + for trigger in triggers: + trigger_data = trigger.model_dump() + trigger_data["payload"] = trigger.payload + trigger_data["trigger_name"] = trigger.trigger_name + await conn.execute( + f""" + INSERT INTO {self.rs_table_name} (runtime_id, interrupt_id, data) + VALUES (?, ?, ?) + """, + ( + runtime_id, + trigger.interrupt_id or "", + serialize_json(trigger_data), + ), + ) + await conn.commit() + + async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger] | None: + """All triggers of ``runtime_id`` in insertion order, or None.""" + async with self._lock: + conn = await self._ensure_conn() + cursor = await conn.execute( + f""" + SELECT data FROM {self.rs_table_name} + WHERE runtime_id = ? + ORDER BY timestamp ASC, id ASC + """, + (runtime_id,), + ) + rows = await cursor.fetchall() + await cursor.close() + if not rows: + return None + return [ + UiPathResumeTrigger.model_validate_json(cast(str, row[0])) for row in rows + ] + + async def delete_trigger( + self, runtime_id: str, trigger: UiPathResumeTrigger + ) -> None: + """Delete one trigger (by its interrupt id).""" + await self.delete_triggers(runtime_id, [trigger]) + + async def delete_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + """Delete triggers by interrupt id.""" + interrupt_ids = [t.interrupt_id for t in triggers if t.interrupt_id] + if not interrupt_ids: + return + placeholders = ",".join("?" for _ in interrupt_ids) + async with self._lock: + conn = await self._ensure_conn() + await conn.execute( + f""" + DELETE FROM {self.rs_table_name} + WHERE runtime_id = ? AND interrupt_id IN ({placeholders}) + """, + (runtime_id, *interrupt_ids), + ) + await conn.commit() + + # --- key-value -------------------------------------------------------- + + async def set_value( + self, runtime_id: str, namespace: str, key: str, value: Any + ) -> None: + """Upsert a value (``str``, ``dict``, ``BaseModel`` or ``None``).""" + if not ( + value is None + or isinstance(value, (str, dict)) + or isinstance(value, BaseModel) + ): + raise TypeError("Value must be str, dict, BaseModel or None.") + value_text = self._dump_value(value) + async with self._lock: + conn = await self._ensure_conn() + await conn.execute( + f""" + INSERT INTO {self.kv_table_name} (runtime_id, namespace, key, value) + VALUES (?, ?, ?, ?) + ON CONFLICT(runtime_id, namespace, key) + DO UPDATE SET + value = excluded.value, + timestamp = (strftime('%Y-%m-%d %H:%M:%S', 'now', 'utc')) + """, + (runtime_id, namespace, key, value_text), + ) + await conn.commit() + + async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: + """Read a value, or None when absent.""" + async with self._lock: + conn = await self._ensure_conn() + cursor = await conn.execute( + f""" + SELECT value FROM {self.kv_table_name} + WHERE runtime_id = ? AND namespace = ? AND key = ? + LIMIT 1 + """, + (runtime_id, namespace, key), + ) + row = await cursor.fetchone() + await cursor.close() + if not row: + return None + return self._load_value(cast(str | None, row[0])) + + # --- lifecycle -------------------------------------------------------- + + async def dispose(self) -> None: + """Close the connection. Safe to call more than once.""" + async with self._lock: + conn, self._conn = self._conn, None + if conn is not None: + await conn.close() + + # --- helpers ---------------------------------------------------------- + + @staticmethod + def _dump_value(value: str | dict[str, Any] | BaseModel | None) -> str | None: + if value is None: + return None + if isinstance(value, str): + return "s:" + value + return "j:" + serialize_json(value) + + @staticmethod + def _load_value(raw: str | None) -> Any: + if raw is None: + return None + if raw.startswith("s:"): + return raw[2:] + if raw.startswith("j:"): + return json.loads(raw[2:]) + return raw + + +__all__ = ["SqliteResumableStorage"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py new file mode 100644 index 00000000..c8f84503 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py @@ -0,0 +1,1206 @@ +"""UiPath trace spans for a Codex run. + +A job is one ``Agent run`` span with the low-code agent span schema, so a coded +Codex agent's trace reads like any other agent's, however often it suspends: + +* ``Agent run - `` (``agentRun``) + + * ``LLM call`` (``llmCall``): one per ``/responses`` call through the gateway + shim. The shim sends the trace context upstream, and the LLM Gateway adds + its ``Model run`` span under it. + * ``Tool call - `` (``toolCall``): durable tools (the child job's + ``ParentSpanId``), and the MCP tools, commands and other items Codex runs + itself. An MCP tool is named by its own name (its server is + ``tool.server``); ``output.value`` is ``{"content": }`` for + ``uipath.eval``, ``output`` the full result. + * ``Agent output`` (``agentOutput``): on a successful run. + +Spans that outlive a suspension (the ``Agent run`` and the ``Tool call`` of a +durable job) are not ended when the execution suspends: they are pushed to +LLMOps as still running and their identity (:func:`span_record`) is saved in +the state database. A resumed execution parents its spans directly under the +saved ``Agent run`` (:func:`restored_span`), as low-code agents do, and the +execution that finishes one completes it with its original id and start time +(:func:`complete_span`). +""" + +from __future__ import annotations + +import json +import logging +import os +import time +import weakref +from contextvars import ContextVar, Token +from typing import Any + +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.trace import ( + NonRecordingSpan, + Span, + SpanContext, + SpanKind, + Status, + StatusCode, + TraceFlags, +) + +logger = logging.getLogger(__name__) + +TRACER_NAME = "uipath_codex_sdk" +TRACE_CONTEXT_FLAG = "EnableTraceContextHeaders" +TRACE_SOURCE = "source=codex-sdk" + +SPAN_AGENT_RUN = "agentRun" +SPAN_AGENT_OUTPUT = "agentOutput" +SPAN_LLM_CALL = "llmCall" +SPAN_TOOL_CALL = "toolCall" +SPAN_INTEGRATION_TOOL = "integrationTool" +SPAN_CONTEXT_GROUNDING_TOOL = "contextGroundingTool" +SPAN_ESCALATION_TOOL = "escalationTool" +SPAN_IXP_TOOL = "ixpTool" +SPAN_VS_ESCALATION_TOOL = "vsEscalationTool" +SPAN_PROCESS_TOOL = "processTool" +SPAN_AGENT_TOOL = "agentTool" + +#: Codex item types that are tool executions (``mcpToolCall`` of a user server, +#: shell commands, file edits, web searches, dynamic tools). +TOOL_ITEM_TYPES = frozenset( + {"mcpToolCall", "commandExecution", "fileChange", "webSearch", "dynamicToolCall"} +) +#: Cap for text attributes (prompts, model output, tool results). +TEXT_CAP = 64_000 + + +def get_tracer() -> trace.Tracer: + return trace.get_tracer(TRACER_NAME) + + +def enable_gateway_model_spans() -> None: + """Default ``EnableTraceContextHeaders`` on, as claude-sdk does. + + ``build_trace_context_headers`` reads the flag and nothing sets it for a + coded agent. This only supplies a default: a deployment that sets + ``UIPATH_FEATURE_EnableTraceContextHeaders`` still decides. + """ + try: + from uipath.core.feature_flags import FeatureFlags + + if FeatureFlags.get_flag(TRACE_CONTEXT_FLAG) is None: + FeatureFlags.configure_flags({TRACE_CONTEXT_FLAG: True}) + except Exception: + logger.debug("could not default %s", TRACE_CONTEXT_FLAG, exc_info=True) + + +def _cap(text: str) -> str: + return text if len(text) <= TEXT_CAP else text[:TEXT_CAP] + "…" + + +def _json(value: Any) -> str: + if isinstance(value, str): + return _cap(value) + try: + return _cap(json.dumps(value, default=str)) + except Exception: + return _cap(str(value)) + + +def span_attributes(span_type: str, **attrs: Any) -> dict[str, Any]: + """Attributes every UiPath span carries, plus ``attrs`` (None dropped).""" + base: dict[str, Any] = { + "type": span_type, + "span_type": span_type, + "uipath.custom_instrumentation": True, + "executionType": 0 if os.environ.get("UIPATH_IS_DEBUG") == "true" else 1, + } + version = os.environ.get("UIPATH_PROCESS_VERSION") + if version: + base["agentVersion"] = version + for key, value in attrs.items(): + if value is None: + continue + if isinstance(value, (str, bool, int, float)): + base[key] = _cap(value) if isinstance(value, str) else value + else: + base[key] = _json(value) + return base + + +def attribute_values(**attrs: Any) -> dict[str, Any]: + """``attrs`` as span attribute values (None dropped, the rest JSON text).""" + return { + key: value if isinstance(value, (bool, int, float)) else _json(value) + for key, value in attrs.items() + if value is not None + } + + +def set_attributes(span: Span, **attrs: Any) -> None: + for key, value in attribute_values(**attrs).items(): + span.set_attribute(key, value) + + +def fail_span(span: Span, error: BaseException) -> None: + # ``CancelledError()`` and the like have no message: still an error. + text = str(error) or type(error).__name__ + span.record_exception(error) + span.set_status(Status(StatusCode.ERROR, text)) + span.set_attribute("error", _cap(text)) + + +# --- agent run ---------------------------------------------------------------- + + +def start_agent_run( + name: str, + *, + agent_name: str, + model: str, + input: Any, + system_prompt: str | None, + user_prompt: str | None, + input_schema: Any, + output_schema: Any, + resumed: bool, + attachments: str | None = None, +) -> Span: + return get_tracer().start_span( + f"Agent run - {name}", + attributes=span_attributes( + SPAN_AGENT_RUN, + agentName=agent_name, + source="runtime", + isConversational=False, + runtimeKind="coded", + framework="codex_sdk", + model=model, + systemPrompt=system_prompt or "", + userPrompt=user_prompt or "", + input=input if input is not None else {}, + inputSchema=input_schema, + outputSchema=output_schema, + resumed=resumed, + attachments=attachments, + ), + ) + + +def emit_agent_output( + output: Any, *, context: Context | None = None, **attrs: Any +) -> None: + """A zero-length ``Agent output`` span under ``context`` (or the current span). + + ``attrs`` are extra attributes, e.g. the ``execution.id`` a non-recording + (restored) parent cannot pass down. + """ + span = get_tracer().start_span( + "Agent output", + context=context, + attributes=span_attributes(SPAN_AGENT_OUTPUT, output=output, **attrs), + ) + span.set_status(Status(StatusCode.OK)) + span.end() + + +# --- spans across executions -------------------------------------------------- + + +def span_record(span: Span) -> dict[str, Any] | None: + """What it takes to complete ``span`` from another process (JSON-safe). + + None for a span that records nothing (a restored or non-sampled one). + """ + ctx = span.get_span_context() + start = getattr(span, "start_time", None) + if not span.is_recording() or not ctx.is_valid or start is None: + return None + parent = getattr(span, "parent", None) + attributes = { + key: list(value) if isinstance(value, tuple) else value + for key, value in (getattr(span, "attributes", None) or {}).items() + } + return { + "trace_id": format(ctx.trace_id, "032x"), + "span_id": format(ctx.span_id, "016x"), + "parent_span_id": format(parent.span_id, "016x") if parent else None, + "name": getattr(span, "name", ""), + "start_time_ns": start, + "attributes": attributes, + } + + +def _span_context(trace_id: str, span_id: str, *, remote: bool) -> SpanContext: + # Sampled: the default sampler drops every child of an unsampled remote + # parent, and the batch processors drop an unsampled span. + return SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=remote, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + + +#: Span attributes the UiPath span processors hand from parent to child. +PROPAGATED_ATTRIBUTES = ("execution.id",) + + +#: Propagated attributes (``execution.id``) of the resumed execution running in +#: this context; see :func:`resumed_attributes`. +_RESUMED_ATTRIBUTES: ContextVar[dict[str, Any] | None] = ContextVar( + "uipath_codex_resumed_attributes", default=None +) +_INSTALLED: weakref.WeakSet[Any] = weakref.WeakSet() + + +def propagated_attributes() -> dict[str, Any]: + """The current span's propagated attributes (e.g. ``execution.id``). + + ``UiPathExecutionTraceProcessorMixin`` copies them only from a recording + parent, so the children of a restored (non-recording) span and a span + completed from a record need them passed explicitly. + """ + span = trace.get_current_span() + attributes = getattr(span, "attributes", None) if span.is_recording() else None + if not attributes: + # Under a restored Agent run: the resumed execution's own attributes. + return dict(_RESUMED_ATTRIBUTES.get() or {}) + return { + key: attributes[key] for key in PROPAGATED_ATTRIBUTES if attributes.get(key) + } + + +class _ResumedAttributesProcessor(SpanProcessor): + """Copies a resumed execution's propagated attributes onto its spans. + + They sit under the restored, non-recording ``Agent run``, which + ``UiPathExecutionTraceProcessorMixin`` cannot copy ``execution.id`` from. + """ + + def on_start(self, span: Any, parent_context: Context | None = None) -> None: + attributes = _RESUMED_ATTRIBUTES.get() + if not attributes: + return + present = span.attributes or {} + for key, value in attributes.items(): + if key not in present: + span.set_attribute(key, value) + + +def resumed_attributes(attributes: dict[str, Any]) -> Token[dict[str, Any] | None]: + """Give every span started in this context ``attributes`` (until reset).""" + provider = trace.get_tracer_provider() + add = getattr(provider, "add_span_processor", None) + if attributes and add is not None and provider not in _INSTALLED: + add(_ResumedAttributesProcessor()) + _INSTALLED.add(provider) + return _RESUMED_ATTRIBUTES.set(dict(attributes) or None) + + +def reset_resumed_attributes(token: Token[dict[str, Any] | None]) -> None: + try: + _RESUMED_ATTRIBUTES.reset(token) + except ValueError: # reset from another context: that context is gone + pass + + +def restored_span(record: dict[str, Any]) -> Span: + """The saved span as a parent for this execution's spans (records nothing).""" + return NonRecordingSpan( + _span_context(record["trace_id"], record["span_id"], remote=True) + ) + + +def _live_trackers() -> list[Any]: + """The ``LiveTrackingSpanProcessor``s on the tracer provider (LLMOps export). + + ``uipath run`` adds one only for a job with tracing enabled, after the + runtime is built, so they are looked up when needed. + """ + try: + from uipath.tracing import LiveTrackingSpanProcessor + except Exception: # pragma: no cover - uipath always ships it + return [] + found: list[Any] = [] + stack = [getattr(trace.get_tracer_provider(), "_active_span_processor", None)] + while stack: + processor = stack.pop() + if processor is None: + continue + if isinstance(processor, LiveTrackingSpanProcessor): + found.append(processor) + continue + for attr in ("_span_processors", "_processors"): + stack.extend(getattr(processor, attr, None) or ()) + return found + + +def push_running(span: Span) -> None: + """Upsert ``span`` to LLMOps as still running, with its current attributes. + + For a span that stays open across a suspension: ending it would export it + as finished. Nothing happens without live tracking (a local run). + """ + try: + from uipath.platform.common._span_utils import SpanStatus + except Exception: # pragma: no cover + return + for processor in _live_trackers(): + span_filter = getattr(processor, "span_filter", None) + if span_filter is not None and not span_filter(span): + continue + try: + # The processor's own pool: non-blocking, and drained at shutdown. + processor._upsert_span_async(span, status_override=SpanStatus.UNSET) + except Exception: + logger.debug("could not push the running span", exc_info=True) + + +def complete_span( + record: dict[str, Any], + *, + attributes: dict[str, Any] | None = None, + error: str | None = None, + drop: tuple[str, ...] = (), +) -> None: + """End a span saved by an earlier execution: same id and start, end now. + + ``error`` (even an empty one) completes it with ERROR status. ``drop`` + names saved attributes that no longer apply (e.g. a suspension's + ``output`` on a faulted run). + + The finished span goes through the provider's span processors, as + ``Span.end()`` would send it: LLMOps live tracking, ``--trace-file`` and + any other exporter get it once. + """ + from opentelemetry.sdk.trace import ReadableSpan + from opentelemetry.sdk.util.instrumentation import InstrumentationScope + + provider = trace.get_tracer_provider() + processor = getattr(provider, "_active_span_processor", None) + if processor is None: + return + try: + saved = { + key: value + for key, value in (record.get("attributes") or {}).items() + if key not in drop + } + final = {**saved, **(attributes or {})} + if error is not None: + final["error"] = _cap(error) + parent = record.get("parent_span_id") + span = ReadableSpan( + name=str(record["name"]), + context=_span_context(record["trace_id"], record["span_id"], remote=False), + parent=( + _span_context(record["trace_id"], parent, remote=False) + if parent + else None + ), + resource=getattr(provider, "resource", None), + attributes=final, + kind=SpanKind.INTERNAL, + status=( + Status(StatusCode.ERROR, error) + if error is not None + else Status(StatusCode.OK) + ), + start_time=int(record["start_time_ns"]), + end_time=time.time_ns(), + instrumentation_scope=InstrumentationScope(TRACER_NAME), + ) + except (KeyError, TypeError, ValueError): + logger.warning("Ignoring an unusable saved span record", exc_info=True) + return + processor.on_end(span) + + +# --- tool calls --------------------------------------------------------------- + + +def start_tool_call( + tool_name: str, + *, + tool_type: str, + call_id: str | None, + arguments: Any, + context: Context | None = None, + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """``Tool call - `` span (exported as a ``toolCall``).""" + args_text = _json(arguments if arguments is not None else {}) + return (tracer or get_tracer()).start_span( + f"Tool call - {tool_name}", + context=context, + attributes=span_attributes( + SPAN_TOOL_CALL, + **{ + "tool.name": tool_name, + "toolName": tool_name, + "toolType": tool_type, + "call_id": call_id, + "input": args_text, + "input.value": args_text, + }, + **attrs, + ), + ) + + +def _tool_output(output: Any, content: str | None = None) -> dict[str, str]: + """``output`` (the full value, LLMOps' ``result``) and ``output.value``. + + ``output.value`` is ``{"content": }``, which ``uipath.eval``'s + tool-output evaluators read; ``content`` defaults to ``output`` as text. + """ + if content is None: + content = _json(output) + return { + "output": _json(output), + "output.value": json.dumps({"content": _cap(content)}), + } + + +def set_tool_output(span: Span, output: Any, *, content: str | None = None) -> None: + for key, value in _tool_output(output, content).items(): + span.set_attribute(key, value) + + +def end_tool_call( + span: Span, + *, + output: Any = None, + error: str | None = None, + content: str | None = None, +) -> None: + if output is not None: + set_tool_output(span, output, content=content) + if error: + span.set_status(Status(StatusCode.ERROR, error)) + span.set_attribute("error", _cap(error)) + else: + span.set_status(Status(StatusCode.OK)) + span.end() + + +#: Child spans a durable tool leaves open across the suspension (see +#: :func:`hold_span`): collected while ``start`` runs, saved with the call. +_held_children: ContextVar[list[dict[str, Any]] | None] = ContextVar( + "codex_held_children", default=None +) + + +def collect_held_spans() -> Token[list[dict[str, Any]] | None]: + """Start collecting :func:`hold_span` records (the tool host, around ``start``).""" + return _held_children.set([]) + + +def held_spans(token: Token[list[dict[str, Any]] | None]) -> list[dict[str, Any]]: + """The records collected since ``token``; stops collecting.""" + records = _held_children.get() or [] + _held_children.reset(token) + return records + + +def hold_span( + span: Span, + *, + output_attribute: str = "output", + output_keys: tuple[str, ...] | None = None, + result_attachment_key: str | None = None, +) -> bool: + """Leave ``span`` open until the durable call's result is used. + + For a child span of a durable ``Tool call`` (e.g. a ``contextGroundingTool`` + span around a DeepRAG run): it is pushed to LLMOps as running and saved + with the call, and the execution that uses the result completes it with + that result. Returns False, and ends the span, outside a durable start. + + ``output_attribute`` names the attribute the result goes into (low-code + escalations use ``result``) and ``output_keys``, when set, keeps only + those keys of a dict result (``outcome``, ``output``). + ``result_attachment_key`` names the result's attachment field (Batch + Transform's ``result``): the span then also gets low-code's ``results`` + (``{id, fileName, mimeType}``) and the attachment as ``out``. + """ + records = _held_children.get() + record = span_record(span) if records is not None else None + if records is None or record is None: + span.set_status(Status(StatusCode.OK)) + span.end() + return False + push_running(span) + record["output_attribute"] = output_attribute + if output_keys: + record["output_keys"] = list(output_keys) + if result_attachment_key: + record["result_attachment_key"] = result_attachment_key + records.append(record) + return True + + +def _only_keys(value: Any, keys: Any) -> Any: + """``value`` with only ``keys`` when it is a dict and keys are given.""" + if not keys or not isinstance(value, dict): + return value + return {k: value.get(k) for k in keys} + + +def _result_attachment_attributes( + record: dict[str, Any], output: Any +) -> dict[str, Any]: + """Low-code's ``results`` and the ``out`` attachment of a result attachment.""" + from ..attachments import merge_span_attachments, span_attachments, to_attachment + + key = record.get("result_attachment_key") + value = output.get(key) if key and isinstance(output, dict) else None + attachment = to_attachment(value) if value else None + if attachment is None: + return {} + merged = merge_span_attachments( + (record.get("attributes") or {}).get("attachments"), + span_attachments([attachment], "out"), + ) + return { + "results": _json( + { + "id": str(attachment.id), + "fileName": attachment.full_name, + "mimeType": attachment.mime_type, + } + ), + **({"attachments": merged} if merged else {}), + } + + +def complete_tool_call( + record: dict[str, Any], + *, + output: Any = None, + error: str | None = None, + attachments: str | None = None, +) -> None: + """End a ``Tool call`` span saved when its job started (see :func:`complete_span`). + + It joins the current execution (``execution.id``), which reads its result. + ``attachments`` (the result's, ``out``) are added to the ``attachments`` + the call started with. + """ + from ..attachments import merge_span_attachments + + merged = merge_span_attachments( + (record.get("attributes") or {}).get("attachments"), attachments + ) + # Children first (inner before outer), with the same result. + for child in record.get("children") or []: + if isinstance(child, dict): + attribute = str(child.get("output_attribute") or "output") + child_output = _only_keys(output, child.get("output_keys")) + complete_span( + child, + attributes={ + **propagated_attributes(), + **({attribute: _json(child_output)} if output is not None else {}), + **_result_attachment_attributes(child, output), + }, + error=error, + ) + # ``output`` may keep only some keys (``output_keys``); ``output.value`` + # keeps the full text the model received (the tool-output evaluators'). + shown = _only_keys(output, record.get("output_keys")) + complete_span( + {k: v for k, v in record.items() if k != "children"}, + attributes={ + **propagated_attributes(), + **( + _tool_output(shown, content=_json(output)) if output is not None else {} + ), + **({"attachments": merged} if attachments and merged else {}), + }, + error=error, + ) + + +def start_integration_tool_span( + display_name: str, + *, + arguments: Any, + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span (``integrationTool``) under the current span. + + The low-code ``IntegrationToolSpanAttributes`` (``toolName``, + ``arguments``, ``result``): the Integration Service request of a + ``Tool call``. The SDK's own ``activity_invoke`` span nests under it. + """ + return (tracer or get_tracer()).start_span( + display_name, + attributes=span_attributes( + SPAN_INTEGRATION_TOOL, + toolName=display_name, + arguments=_json(arguments if arguments is not None else {}), + **attrs, + ), + ) + + +def end_integration_tool_span( + span: Span, *, result: Any = None, error: BaseException | str | None = None +) -> None: + if result is not None: + span.set_attribute("result", _json(result)) + if isinstance(error, BaseException): + fail_span(span, error) + elif error: + span.set_status(Status(StatusCode.ERROR, error)) + span.set_attribute("error", _cap(error)) + else: + span.set_status(Status(StatusCode.OK)) + span.end() + + +def start_internal_tool_span( + display_name: str, + *, + arguments: Any, + tool_type: str = "Internal", + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span of a low-code internal tool, under the ``Tool call``. + + A ``toolCall`` span (low-code ``InternalToolSpanAttributes``: ``toolName``, + ``input``, ``output``). ``tool.name`` and ``toolType`` are set too: the + LLMOps exporter reads them for every ``toolCall`` span (``toolType`` + defaults to Integration). ``tool.synthesized`` keeps the tool-call + evaluators from counting it as a second call: the call is the parent + ``Tool call`` span. + """ + return (tracer or get_tracer()).start_span( + display_name, + attributes=span_attributes( + SPAN_TOOL_CALL, + toolName=display_name, + toolType=tool_type, + **{"tool.name": display_name, "tool.synthesized": True}, + input=_json(arguments if arguments is not None else {}), + **{"input.mime_type": "application/json"}, + **attrs, + ), + ) + + +def start_context_grounding_span( + display_name: str, + *, + retrieval_mode: str, + query: str, + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span (``contextGroundingTool``) under the current span. + + The low-code ``ContextGroundingToolSpanAttributes`` (``retrieval_mode``, + ``query``, ``threshold``, ``number_of_results``, ``folder_path_prefix``, + ``file_extension``, ``system_index``) of a ``Tool call``; its ``output`` is + the documents. The search runs under a :func:`start_retriever_span` child. + """ + return (tracer or get_tracer()).start_span( + display_name, + attributes=span_attributes( + SPAN_CONTEXT_GROUNDING_TOOL, + retrieval_mode=retrieval_mode, + query=query, + **attrs, + ), + ) + + +def start_ixp_tool_span( + display_name: str, + *, + arguments: Any, + project_name: str, + version_tag: str, + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span (``ixpTool``) of an IXP extraction. + + The low-code ``IxpToolSpanAttributes``: ``arguments``, ``projectName``, + ``versionTag``, then ``extractionId`` and ``documentId``; it closes with + ``result``. + """ + return (tracer or get_tracer()).start_span( + display_name, + attributes=span_attributes( + SPAN_IXP_TOOL, + arguments=arguments if arguments is not None else {}, + projectName=project_name, + versionTag=version_tag, + **attrs, + ), + ) + + +def start_vs_escalation_tool_span( + display_name: str, + *, + ixp_tool_id: str, + storage_bucket_name: str | None, + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span (``vsEscalationTool``) of a Document Validation task. + + The low-code ``VsEscalationToolSpanAttributes``: ``arguments``, + ``ixpToolId``, ``storageBucketName``, then ``operationId`` and + ``taskUrl``; it closes with ``result``. + """ + return (tracer or get_tracer()).start_span( + display_name, + attributes=span_attributes( + SPAN_VS_ESCALATION_TOOL, + arguments={}, + ixpToolId=ixp_tool_id, + storageBucketName=storage_bucket_name, + **attrs, + ), + ) + + +def start_process_tool_span( + process_name: str, + *, + arguments: Any, + kind: str = "process", + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span (``processTool``, or ``agentTool`` for an agent). + + The low-code ``ProcessToolSpanAttributes`` (``arguments``, then the + ``output``). Only a simulated process call has one: a real call's job is + linked to the ``Tool call`` span itself (its ``ParentSpanId``). + """ + span_type = SPAN_AGENT_TOOL if kind == "agent" else SPAN_PROCESS_TOOL + extra: dict[str, Any] = {"toolName": process_name} if kind == "agent" else {} + return (tracer or get_tracer()).start_span( + process_name, + attributes=span_attributes( + span_type, + arguments=arguments if arguments is not None else {}, + **extra, + **attrs, + ), + ) + + +def start_escalation_tool_span( + display_name: str, + *, + arguments: Any, + channel_type: str = "actionCenter", + tracer: trace.Tracer | None = None, + **attrs: Any, +) -> Span: + """```` span (``escalationTool``) under the current span. + + The low-code ``EscalationToolSpanAttributes``: ``arguments`` (the task + data), ``channelType``, and once the task exists ``taskId``, ``taskUrl`` + and ``assignedTo``; it closes with ``result`` = ``{outcome, output}``. + """ + return (tracer or get_tracer()).start_span( + display_name, + attributes=span_attributes( + SPAN_ESCALATION_TOOL, + arguments=arguments if arguments is not None else {}, + channelType=channel_type, + **attrs, + ), + ) + + +def end_context_grounding_span( + span: Span, *, result: str | None = None, error: BaseException | None = None +) -> None: + """End it with ``result`` (the documents JSON, kept whole) or ``error``.""" + if result is not None: + span.set_attribute("output", result) + _end(span, error) + + +def start_retriever_span(query: str, *, tracer: trace.Tracer | None = None) -> Span: + """``ContextGroundingRetriever`` span (OpenInference ``RETRIEVER``). + + The span langchain's OpenInference instrumentation records for its + ``ContextGroundingRetriever``: ``input.value`` is the query and + ``output.value`` ``{"documents": [...]}``, which the context precision + and faithfulness evaluators read. It is separate from the + ``contextGroundingTool`` span because the LLMOps exporter takes a span's + ``SpanType`` from ``openinference.span.kind`` when it has one. + """ + return (tracer or get_tracer()).start_span( + "ContextGroundingRetriever", + attributes={ + "openinference.span.kind": "RETRIEVER", + "input.value": _cap(query), + "input.mime_type": "text/plain", + }, + ) + + +def end_retriever_span( + span: Span, *, result: str | None = None, error: BaseException | None = None +) -> None: + """End it with ``result`` (the documents JSON, kept whole) or ``error``.""" + if result is not None: + span.set_attribute("output.value", result) + span.set_attribute("output.mime_type", "application/json") + _end(span, error) + + +def _end(span: Span, error: BaseException | None) -> None: + if error is not None: + fail_span(span, error) + else: + span.set_status(Status(StatusCode.OK)) + span.end() + + +def item_tool(item: dict[str, Any]) -> tuple[str, str, Any] | None: + """``(tool name, tool type, arguments)`` of a Codex tool item, else None.""" + itype = item.get("type") + if itype not in TOOL_ITEM_TYPES: + return None + if itype == "mcpToolCall": + return (item.get("tool") or "tool", "Mcp", item.get("arguments")) + if itype == "dynamicToolCall": + return (item.get("tool") or "tool", "Internal", item.get("arguments")) + if itype == "commandExecution": + return ("shell", "Internal", {"command": item.get("command")}) + if itype == "fileChange": + return ("apply_patch", "Internal", {"changes": item.get("changes")}) + return ("web_search", "Internal", {"query": item.get("query")}) + + +def item_tool_result(item: dict[str, Any]) -> tuple[Any, str | None]: + """``(output, error)`` of a completed Codex tool item.""" + itype = item.get("type") + status = str(item.get("status") or "") + error: str | None = None + if itype == "mcpToolCall": + output = item.get("result") + err = item.get("error") + if err: + error = err.get("message") if isinstance(err, dict) else str(err) + elif itype == "commandExecution": + output = { + "output": item.get("aggregated_output"), + "exit_code": item.get("exit_code"), + } + else: + output = {k: v for k, v in item.items() if k not in ("id", "type")} + if error is None and status in ("failed", "declined"): + error = f"{itype} {status}" + return output, error + + +def _change_line(change: Any) -> str: + if not isinstance(change, dict): + return str(change) + kind = change.get("kind") + kind = kind if isinstance(kind, dict) else {"type": kind} + line = f"{kind.get('type') or 'update'} {change.get('path') or ''}".rstrip() + return f"{line} -> {kind['move_path']}" if kind.get("move_path") else line + + +def item_tool_text(item: dict[str, Any]) -> str | None: + """Text result of a completed Codex tool item (``output.value``'s content). + + Shell output, an MCP result's text blocks, a summary of the file changes; + None for the full output as JSON. + """ + itype = item.get("type") + if itype == "commandExecution": + return item.get("aggregated_output") or "" + if itype == "mcpToolCall": + result = item.get("result") + if isinstance(result, dict) and (text := _content_text(result.get("content"))): + return text + return None + if itype == "dynamicToolCall": + return _content_text(item.get("content_items")) or None + if itype == "fileChange": + return "\n".join(_change_line(c) for c in item.get("changes") or []) + if itype == "webSearch": + return None if item.get("results") else item.get("query") or "" + return None + + +def _item_attributes(item: dict[str, Any]) -> dict[str, Any]: + if item.get("type") == "mcpToolCall" and item.get("server"): + return {"tool.server": item["server"], "mcp.server": item["server"]} + return {} + + +class ItemSpans: + """Open ``Tool call`` spans of the Codex items still running.""" + + def __init__(self) -> None: + self._open: dict[str, Span] = {} + + def start(self, item: dict[str, Any]) -> None: + described = item_tool(item) + item_id = item.get("id") + if described is None or not item_id or item_id in self._open: + return + name, tool_type, arguments = described + self._open[item_id] = start_tool_call( + name, + tool_type=tool_type, + call_id=item_id, + arguments=arguments, + **_item_attributes(item), + ) + + def finish(self, item: dict[str, Any]) -> None: + span = self._open.pop(item.get("id") or "", None) + if span is None: + return + output, error = item_tool_result(item) + end_tool_call(span, output=output, error=error, content=item_tool_text(item)) + + def close_all(self, reason: str) -> None: + for span in self._open.values(): + span.set_attribute("status", reason) + span.end() + self._open.clear() + + +# --- model calls -------------------------------------------------------------- + + +def _content_text(content: Any) -> str: + if isinstance(content, str): + return content + parts: list[str] = [] + if isinstance(content, list): + for block in content: + if isinstance(block, dict): + text = block.get("text") or block.get("output") or "" + if isinstance(text, str) and text: + parts.append(text) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + + +def latest_input_text(payload: dict[str, Any]) -> str: + """Text of the newest Responses ``input`` item that has any.""" + items = payload.get("input") + if isinstance(items, str): + return _cap(items) + if not isinstance(items, list): + return "" + for item in reversed(items): + if not isinstance(item, dict): + continue + if item.get("type") == "function_call_output": + text = _content_text(item.get("output")) + else: + text = _content_text(item.get("content")) + if text: + return _cap(text) + return "" + + +def has_output_schema(payload: dict[str, Any]) -> bool: + """Whether a Responses request forces a JSON schema on the output text.""" + text = payload.get("text") + fmt = text.get("format") if isinstance(text, dict) else None + return isinstance(fmt, dict) and fmt.get("type") == "json_schema" + + +def response_summary( + response: dict[str, Any], *, schema_output: bool = False +) -> dict[str, Any]: + """Output text, tool calls and usage of a Responses ``response`` object. + + With ``schema_output`` (the request forced a JSON schema), the text of a + response that also calls tools is dropped: the model fills the schema + with placeholders (``{"issue_key": "pending"}``) to go with its tool + calls, and only the final response carries the agent's output. + """ + texts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + for item in response.get("output") or []: + if not isinstance(item, dict): + continue + itype = item.get("type") + if itype == "message": + for block in item.get("content") or []: + if isinstance(block, dict) and block.get("type") == "output_text": + texts.append(block.get("text") or "") + elif itype in ("function_call", "custom_tool_call"): + tool_calls.append( + { + "id": item.get("call_id"), + "name": item.get("name"), + "arguments": item.get("arguments") or item.get("input"), + } + ) + usage = response.get("usage") or {} + if schema_output and tool_calls: + texts = [] + summary: dict[str, Any] = {"text": "\n".join(t for t in texts if t)} + if tool_calls: + summary["toolCalls"] = tool_calls + if usage: + summary["usage"] = { + "promptTokens": usage.get("input_tokens"), + "completionTokens": usage.get("output_tokens"), + "totalTokens": usage.get("total_tokens"), + } + return summary + + +class SseCompletedReader: + """Rebuilds the final ``response`` from a relayed Responses SSE stream. + + Takes the ``response.completed`` event, and fills its ``output`` from the + ``response.output_item.done`` events when the event carries none (Codex + itself builds the turn from those). Only the event being received is + buffered, plus the finished output items. + """ + + _EVENTS = (b"response.completed", b"response.done", b"response.output_item.done") + + def __init__(self) -> None: + self._buffer = b"" + self._items: list[dict[str, Any]] = [] + self._completed: dict[str, Any] | None = None + + @property + def response(self) -> dict[str, Any] | None: + if self._completed is None: + return None + if self._completed.get("output") or not self._items: + return self._completed + return {**self._completed, "output": list(self._items)} + + def feed(self, chunk: bytes) -> None: + self._buffer += chunk.replace(b"\r\n", b"\n") + while b"\n\n" in self._buffer: + event, self._buffer = self._buffer.split(b"\n\n", 1) + self._parse(event) + + def _parse(self, event: bytes) -> None: + data = b"\n".join( + line[5:].lstrip() + for line in event.split(b"\n") + if line.startswith(b"data:") + ) + if not any(name in data for name in self._EVENTS): + return + try: + parsed = json.loads(data) + except ValueError: + return + if not isinstance(parsed, dict): + return + if parsed.get("type") == "response.output_item.done": + item = parsed.get("item") + if isinstance(item, dict): + self._items.append(item) + elif isinstance(parsed.get("response"), dict): + self._completed = parsed["response"] + + +class ModelCallSpan: + """The ``LLM call`` span of one relayed ``/responses`` request.""" + + def __init__(self, parent: Context | None, payload: dict[str, Any], model: str): + self._schema_output = has_output_schema(payload) + self.span = get_tracer().start_span( + "LLM call", + context=parent, + attributes=span_attributes( + SPAN_LLM_CALL, input=latest_input_text(payload), model=model + ), + ) + + def trace_headers(self) -> dict[str, str]: + """Headers that let the LLM Gateway add its ``Model run`` span here.""" + token = context_api.attach(trace.set_span_in_context(self.span)) + try: + from uipath.platform.chat.llm_trace_context import ( + build_trace_context_headers, + ) + + return build_trace_context_headers(extra_baggage=[TRACE_SOURCE]) + except Exception: + logger.debug("could not build trace context headers", exc_info=True) + return {} + finally: + context_api.detach(token) + + def finish( + self, + *, + response: dict[str, Any] | None = None, + status: int | None = None, + error: str | None = None, + ) -> None: + if response is not None: + summary = response_summary(response, schema_output=self._schema_output) + set_attributes( + self.span, + output=summary.get("text"), + content=summary.get("text"), + toolCalls=summary.get("toolCalls"), + usage=summary.get("usage"), + ) + if status is not None: + self.span.set_attribute("http.status_code", status) + if error: + self.span.set_status(Status(StatusCode.ERROR, error)) + self.span.set_attribute("error", _cap(error)) + else: + self.span.set_status(Status(StatusCode.OK)) + self.span.end() + + +__all__ = [ + "ItemSpans", + "attribute_values", + "ModelCallSpan", + "SseCompletedReader", + "TOOL_ITEM_TYPES", + "TRACER_NAME", + "complete_span", + "complete_tool_call", + "emit_agent_output", + "enable_gateway_model_spans", + "end_tool_call", + "fail_span", + "get_tracer", + "item_tool_text", + "latest_input_text", + "push_running", + "response_summary", + "restored_span", + "set_attributes", + "set_tool_output", + "span_attributes", + "span_record", + "start_agent_run", + "reset_resumed_attributes", + "resumed_attributes", + "start_tool_call", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/tool_host.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/tool_host.py new file mode 100644 index 00000000..f7563ac4 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/tool_host.py @@ -0,0 +1,1133 @@ +"""MCP endpoint that serves UiPath tools to Codex. + +It serves two kinds of tools on one server: + +* **durable** tools (below), whose calls are held until the run suspends; +* **immediate** tools (:class:`~uipath_codex_sdk.tools.UiPathTool`, e.g. + Integration Service activities), answered as soon as their work is done: + the arguments are validated, ``tool.call`` runs in a copy of the run's + context under a ``Tool call`` span, with a host-side timeout, and the result + (or an ``isError`` result) is returned. No rollout probe, no session store, + no hold. A call still running when the run suspends is cancelled and + reported to the model as having an unknown outcome. + +Mounted on the gateway shim at ``/mcp`` and registered with Codex as the +remote MCP server :data:`SERVER_NAME`. It is a stateless streamable-HTTP MCP +server that answers with plain JSON (no SSE stream, no sessions) and only +accepts its own per-run bearer token. + +``tools/call`` for a durable tool: + +1. validates the arguments (invalid → an ``isError`` result straight away); +2. waits until Codex has written the ``function_call`` with this call id to the + rollout — it is recorded before the call runs but not flushed, and a + suspend must never lose it; +3. records the intent, starts the job (``tool.start``, inside the context the + run started in, so resource overwrites and the ``Agent run`` span apply, + under a ``Tool call`` span that becomes the job's ``ParentSpanId``), and + saves the pending call with that span, which stays open until the + execution that uses the job's result completes it; +4. never answers: the call is held until the runtime stops Codex and suspends. + +Job attachments: an argument at an attachment field of a tool's input schema +is resolved against the run's known attachments before validation (the model +may pass just ``{"ID": ...}``); an unknown ID is answered with LangChain's +error text and the tool does not run. As in LangChain (whose +``StaticArgsHandler`` merges the pre-filled values into the tool call before +``job_attachment_wrapper`` resolves it), the IDs checked are those of the +arguments once the tool's pre-filled values are merged in +(``prefilled_args``): a model value a pre-filled one replaces is not checked, +the pre-filled one is (an escalation's pre-filled ones are not: LangChain +never resolves an escalation's attachments). The tool still gets the model's arguments (resolved), +which are what is saved for resume; it merges its pre-filled values itself. +The attachments found at the attachment fields of a tool's output schema +become known to the run. + +A tool that raises a ``UiPathRuntimeError`` (e.g. Analyze Files given a file +over its size limit) fails the run, as in LangChain: the error is recorded in +:attr:`DurableToolHost.fatal_error` and the runtime stops Codex. + +Eval tool mocking (:mod:`~uipath_codex_sdk.runtime.mocking`): a call the +active mocker simulates is answered with the mocked value in the same turn, +under a ``Tool call`` span, after its arguments are validated. A durable one +starts no job and is never held or saved, so the run does not suspend on it. +A custom immediate tool is mocked the same way; the built-in immediate tools +(``mocks_in_call``) mock their platform call inside ``call`` instead. + +A start failure is answered with an ``isError`` result the model can act on. +Once the runtime starts stopping Codex (:meth:`DurableToolHost.close`), a late +call is answered with an error and starts nothing. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import inspect +import json +import logging +import secrets +import time +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Coroutine + +from aiohttp import web +from opentelemetry import trace +from opentelemetry.trace import Status, StatusCode +from uipath.runtime.errors import UiPathRuntimeError + +from ..attachments import ( + AttachmentStore, + Path, + attachment_paths, + merge_span_attachments, + span_attachments, + to_attachment, + values_at, +) +from ..tool_results import ToolResultStore +from ..tools import ( + AnyUiPathTool, + DurableToolContext, + ToolContext, + UiPathDurableTool, + UiPathDurableToolError, + UiPathTool, + UiPathToolError, + is_durable_tool, + tool_type_of, +) +from .errors import UiPathRunTerminatedError +from .mocking import ( + MockSpan, + MockSpec, + is_simulated, + mark_simulated, + mock_spec_of, + mocked_text, + wrap, +) +from .session_store import STATE_STARTED, STATE_STARTING, CodexSessionStore, PendingCall +from .telemetry import ( + _json, + _only_keys, + collect_held_spans, + end_tool_call, + fail_span, + held_spans, + push_running, + set_tool_output, + span_record, + start_tool_call, +) + +logger = logging.getLogger(__name__) + +#: MCP server name the tool host is registered under (namespace ``mcp__uipath_tools``). +SERVER_NAME = "uipath_tools" +#: Env var Codex reads the tool host bearer token from. +TOKEN_ENV = "UIPATH_CODEX_TOOLS_TOKEN" +#: Prefix added to durable tool descriptions when instructions are enabled. +LONG_RUNNING_TAG = "[long-running]" + +#: Timeout of an immediate tool that sets none. +DEFAULT_IMMEDIATE_TIMEOUT_SEC = 300.0 +#: How long :meth:`DurableToolHost.cancel_inflight` waits for cancelled calls. +CANCEL_WAIT_SEC = 5.0 +#: Result reported for an immediate call cut off by a suspension. +UNKNOWN_OUTCOME_OUTPUT = ( + "The run was suspended while this call was still running; its outcome is " + "unknown and it may have taken effect. Check before calling it again." +) + +_PROTOCOL_VERSION = "2025-06-18" +_TOKEN_BYTES = 32 + +#: Probe: ``await probe(call_id)`` → True once the call is in the rollout. +CallProbe = Callable[[str], Awaitable[bool]] + + +@dataclass +class HeldCall: + """A durable call whose job started and whose request is being held.""" + + call_id: str + tool: str + value: Any + held_at: float + + +class DurableToolHost: + """Serves UiPath tools over MCP: holds durable calls, answers immediate ones. + + Args: + tools: Durable and immediate tools to serve, by model-facing name. + store: Session store the pending durable calls are written to + (required when there is a durable tool). + runtime_id: Runtime id handed to each tool. + context: Context the tools run in (a copy per call). + tag_descriptions: Prefix durable descriptions with ``[long-running]``. + tracer: Tracer for the tool spans (default: the package tracer). + agent_input: The run's validated input (``ToolContext.agent_input``). + attachments: The run's known job attachments. + model: The agent's model (``ToolContext.model``). + """ + + def __init__( + self, + tools: list[UiPathDurableTool | UiPathTool], + *, + store: CodexSessionStore | None, + runtime_id: str, + context: contextvars.Context | None = None, + tag_descriptions: bool = True, + tracer: trace.Tracer | None = None, + agent_input: dict[str, Any] | None = None, + attachments: AttachmentStore | None = None, + model: str | None = None, + results: ToolResultStore | None = None, + ) -> None: + self._tools: dict[str, Any] = {tool.name: tool for tool in tools} + self.attachments = attachments if attachments is not None else AttachmentStore() + self._model = model + self.results = results if results is not None else ToolResultStore() + #: The error of a tool that failed the run (see the module docstring). + self.fatal_error: Exception | None = None + self.fatal = asyncio.Event() + self._durable_names = frozenset( + tool.name for tool in tools if is_durable_tool(tool) + ) + if self._durable_names and store is None: + raise ValueError("Durable tools need a session store.") + self._store = store + self._agent_input = agent_input + self._runtime_id = runtime_id + self._context = context + self._tag = tag_descriptions + self._tracer = tracer + self.token = secrets.token_urlsafe(_TOKEN_BYTES) + self._probe: CallProbe | None = None + self._held: dict[str, HeldCall] = {} + self._starting: set[str] = set() + self._order = 0 + self._release = asyncio.Event() + self._last_activity = time.monotonic() + self._lock = asyncio.Lock() + self._closing = False + self._inflight: dict[str, asyncio.Task[tuple[str, bool]]] = {} + self._outcomes: dict[str, str] = {} + self._failed: set[str] = set() + self._mocking: set[str] = set() + self._anon = 0 + + # --- state read by the suspend watcher --------------------------------- + + @property + def held(self) -> dict[str, HeldCall]: + return dict(self._held) + + @property + def starting_count(self) -> int: + return len(self._starting) + + @property + def mocking_count(self) -> int: + """Simulated durable calls being answered (neither held nor starting).""" + return len(self._mocking) + + @property + def last_activity(self) -> float: + return self._last_activity + + @property + def closing(self) -> bool: + return self._closing + + @property + def has_durable(self) -> bool: + return bool(self._durable_names) + + @property + def inflight_count(self) -> int: + """Immediate calls whose work is running.""" + return len(self._inflight) + + @property + def inflight_ids(self) -> list[str]: + return list(self._inflight) + + def immediate_outcome(self, call_id: str) -> str | None: + """Text an immediate call was answered with (or its unknown outcome).""" + return self._outcomes.get(call_id) + + async def cancel_inflight(self) -> None: + """Cancel the immediate calls still running (teardown). + + Each one is recorded with :data:`UNKNOWN_OUTCOME_OUTPUT`: its side + effect may already have happened. + """ + tasks = dict(self._inflight) + if not tasks: + return + for call_id, task in tasks.items(): + if not call_id.startswith("_anon"): + self._outcomes[call_id] = UNKNOWN_OUTCOME_OUTPUT + task.cancel() + await asyncio.wait(list(tasks.values()), timeout=CANCEL_WAIT_SEC) + + def close(self) -> None: + """Stop starting jobs: Codex is being stopped. + + Set by the runtime just before it kills Codex. A ``tools/call`` that + arrives later (Codex had started the call but the request was still in + flight) is answered with an error instead of starting a job the + suspension would not know about. + """ + self._closing = True + + def bind(self, probe: CallProbe) -> None: + """Set how to check that a call is in the rollout (once the thread is known).""" + self._probe = probe + + def set_order_base(self, base: int) -> None: + self._order = max(self._order, base) + + async def release_all(self) -> None: + """Let every held request finish (teardown; the client is gone by then).""" + self._release.set() + await asyncio.sleep(0) + + def tool_list(self) -> list[dict[str, Any]]: + out = [] + for tool in self._tools.values(): + description = tool.description + durable = tool.name in self._durable_names + if durable and self._tag and not description.startswith(LONG_RUNNING_TAG): + description = f"{LONG_RUNNING_TAG} {description}" + out.append( + { + "name": tool.name, + "description": description, + "inputSchema": tool.input_schema, + } + ) + return out + + # --- HTTP --------------------------------------------------------------- + + async def handle(self, request: web.Request) -> web.StreamResponse: + if request.method != "POST": + # Stateless server: no standalone SSE stream and no sessions. + return web.Response(status=405, headers={"Allow": "POST"}) + if request.headers.get("Authorization", "") != f"Bearer {self.token}": + return web.json_response({"error": "unauthorized"}, status=401) + try: + message = json.loads(await request.read()) + except ValueError: + return _rpc_error(None, -32700, "parse error", status=400) + if not isinstance(message, dict): + return _rpc_error(None, -32600, "batches are not supported", status=400) + + method = message.get("method") + mid = message.get("id") + if mid is None: + return web.Response(status=202) # notification + params = message.get("params") or {} + if method == "initialize": + version = params.get("protocolVersion") or _PROTOCOL_VERSION + return _rpc_result( + mid, + { + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "uipath-tools", "version": "1.0.0"}, + }, + ) + if method == "ping": + return _rpc_result(mid, {}) + if method == "tools/list": + return _rpc_result(mid, {"tools": self.tool_list()}) + if method == "tools/call": + return await self._tools_call(mid, params) + return _rpc_error(mid, -32601, f"method not found: {method}") + + async def _tools_call(self, mid: Any, params: dict[str, Any]) -> web.StreamResponse: + self._last_activity = time.monotonic() + if self._closing: + return _tool_error(mid, _CLOSING_TEXT) + name = params.get("name") + tool = self._tools.get(name) if isinstance(name, str) else None + if tool is None: + return _tool_error(mid, f"Unknown tool: {name!r}") + if name not in self._durable_names: + return await self._call_immediate(mid, tool, params) + meta = params.get("_meta") or {} + call_id = meta.get("callId") if isinstance(meta, dict) else None + if not isinstance(call_id, str) or not call_id: + return _tool_error( + mid, f"Tool '{name}' cannot run: the call carries no call id." + ) + raw_args, errors = self._resolve_attachments(tool, params) + if errors: + return _tool_error(mid, "\n".join(errors)) + try: + args = tool.validate_args(raw_args) + except ValueError as e: + return _tool_error(mid, f"Invalid arguments for tool '{name}': {e}") + spec = self._simulated_spec(tool) + if spec is not None: + # Simulated: answered now; no probe, no pending call, no hold. + return await self._call_mocked(mid, tool, call_id, args, spec, durable=True) + # The same call again (a client retry): keep holding, never start a + # second job. No await between this check and the add below, so a + # duplicate cannot slip in while the first one starts. + if call_id in self._held or call_id in self._starting: + return await self._hold(mid) + + self._starting.add(call_id) + try: + error = await self._start(tool, call_id, args) + finally: + self._starting.discard(call_id) + self._last_activity = time.monotonic() + if error is not None: + return _tool_error(mid, error) + return await self._hold(mid) + + # --- immediate tools ---------------------------------------------------- + + async def _call_immediate( + self, mid: Any, tool: UiPathTool, params: dict[str, Any] + ) -> web.StreamResponse: + meta = params.get("_meta") or {} + call_id = meta.get("callId") if isinstance(meta, dict) else None + if not isinstance(call_id, str) or not call_id: + call_id = None + raw_args, errors = self._resolve_attachments(tool, params) + if errors: + return _tool_error(mid, "\n".join(errors)) + try: + args = tool.validate_args(raw_args) + except ValueError as e: + return _tool_error(mid, f"Invalid arguments for tool '{tool.name}': {e}") + if self._closing: # validation may have yielded + return _tool_error(mid, _CLOSING_TEXT) + if not getattr(tool, "mocks_in_call", False): + spec = self._simulated_spec(tool) + if spec is not None: + return await self._call_mocked( + mid, tool, call_id, args, spec, durable=False + ) + ctx = ToolContext( + call_id=call_id, + runtime_id=self._runtime_id, + tool_name=tool.name, + agent_input=self._agent_input, + attachments=self.attachments, + model=self._model, + ) + key = self._task_key(call_id) + task = self._spawn_in_context(self._traced_call(tool, args, ctx)) + timeout = getattr(tool, "timeout_sec", None) or DEFAULT_IMMEDIATE_TIMEOUT_SEC + return await self._await_hosted(mid, key, call_id, task, timeout, tool.name) + + def _task_key(self, call_id: str | None) -> str: + """Key of a hosted call in ``_inflight`` (its call id when free).""" + if call_id is None or call_id in self._inflight: + self._anon += 1 + return f"_anon{self._anon}" + return call_id + + async def _await_hosted( + self, + mid: Any, + key: str, + call_id: str | None, + task: asyncio.Task[tuple[str, bool]], + timeout: float, + name: str, + ) -> web.StreamResponse: + """Answer with a hosted call's ``(text, is_error)``, within ``timeout``. + + The call is in flight (``inflight_count``, cancelled by + :meth:`cancel_inflight`) until it ends; its answer is recorded by call + id for :meth:`immediate_outcome`. + """ + self._inflight[key] = task + self._last_activity = time.monotonic() + try: + try: + text, is_error = await asyncio.wait_for(asyncio.shield(task), timeout) + except asyncio.TimeoutError: + task.cancel() + await asyncio.wait([task], timeout=CANCEL_WAIT_SEC) + text = f"Tool '{name}' did not finish within {timeout:g}s." + is_error = True + except asyncio.CancelledError: + if task.cancelled() and call_id is not None: + # Cancelled by cancel_inflight: the outcome is recorded. + text = self._outcomes.get(call_id, UNKNOWN_OUTCOME_OUTPUT) + is_error = True + else: + task.cancel() # the request itself was cancelled + raise + finally: + self._inflight.pop(key, None) + self._last_activity = time.monotonic() + if call_id is not None and call_id not in self._outcomes: + self._outcomes[call_id] = text + if is_error: + self._failed.add(call_id) + if is_error: + return _tool_error(mid, text) + return _tool_result(mid, text) + + async def _traced_call( + self, tool: UiPathTool, args: dict[str, Any], ctx: ToolContext + ) -> tuple[str, bool]: + """``tool.call`` under a ``Tool call`` span; ``(text, is_error)``. + + Runs inside the run's context (a child of ``Agent run`` / ``Resume``). + The span ends when the call does; it is never saved. + """ + kind = str(getattr(tool, "kind", None) or "function") + extra = getattr(tool, "span_attributes", None) + shown = _span_args(tool, args, ctx) + span = start_tool_call( + tool.name, + tool_type=tool_type_of(tool, kind), + call_id=ctx.call_id, + arguments=shown, + tracer=self._tracer, + attachments=span_attachments(input_attachments(tool, shown), "in"), + **{ + "tool.kind": kind, + "tool.server": SERVER_NAME, + **(extra if isinstance(extra, dict) else {}), + }, + ) + with trace.use_span(span, end_on_exit=False): + try: + text = await tool.call(args, ctx) + except UiPathToolError as e: + end_tool_call(span, error=str(e) or type(e).__name__) + return str(e), True + except asyncio.CancelledError: + end_tool_call(span, error="interrupted") + raise + except UiPathRuntimeError as e: + # Fails the run (LangChain raises it out of the agent graph). + logger.error("tool %s failed the run: %s", tool.name, e) + fail_span(span, e) + span.end() + if self.fatal_error is None: + self.fatal_error = e + self.fatal.set() + return f"Tool '{tool.name}' failed: {e}", True + except Exception as e: + logger.exception("tool %s failed", tool.name) + fail_span(span, e) + span.end() + return f"Tool '{tool.name}' failed: {e}", True + if not isinstance(text, str): + text = json.dumps(text, default=str) + parsed = _parsed(text) + harvested = self.attachments.harvest(output_paths(tool), parsed) + if harvested: + merged = merge_span_attachments( + (getattr(span, "attributes", None) or {}).get("attachments"), + span_attachments(harvested, "out"), + ) + if merged: + span.set_attribute("attachments", merged) + end_tool_call(span, output=parsed, content=text) + return text, False + + # --- simulated (eval-mocked) calls --------------------------------------- + + def _simulated_spec(self, tool: AnyUiPathTool) -> MockSpec | None: + """The tool's mock spec when the run's mocker simulates it, else None. + + Checked in the run's context: the handler's own may not have the + mocking ContextVars. + """ + + def check() -> MockSpec | None: + try: + from uipath.eval.mocks._mock_context import mocker_context + except Exception: # pragma: no cover - eval extras missing + return None + if mocker_context.get() is None: + return None + spec = mock_spec_of(tool) + return spec if is_simulated(spec.name) else None + + if self._context is None: + return check() + return self._context.run(check) + + async def _call_mocked( + self, + mid: Any, + tool: Any, + call_id: str | None, + args: dict[str, Any], + spec: MockSpec, + *, + durable: bool, + ) -> web.StreamResponse: + """Answer a simulated call with the mocked value (see :meth:`_traced_mock`). + + A client retry of the same call id gets the first answer: the mocker + is not asked again (a mockito answer sequence stays in step). + """ + if call_id is not None: + if call_id in self._outcomes: + text = self._outcomes[call_id] + if call_id in self._failed: + return _tool_error(mid, text) + return _tool_result(mid, text) + running = self._inflight.get(call_id) + if running is not None: + await asyncio.wait([running]) + if running.cancelled(): + return _tool_error( + mid, self._outcomes.get(call_id, UNKNOWN_OUTCOME_OUTPUT) + ) + text, is_error = running.result() + return _tool_error(mid, text) if is_error else _tool_result(mid, text) + ctx: ToolContext | DurableToolContext + if durable: + assert call_id is not None # durable calls always carry one + ctx = DurableToolContext( + call_id=call_id, + runtime_id=self._runtime_id, + tool_name=tool.name, + agent_input=self._agent_input, + attachments=self.attachments, + model=self._model, + results=self.results, + ) + else: + ctx = ToolContext( + call_id=call_id, + runtime_id=self._runtime_id, + tool_name=tool.name, + agent_input=self._agent_input, + attachments=self.attachments, + model=self._model, + ) + key = self._task_key(call_id) + task = self._spawn_in_context( + self._traced_mock(tool, args, ctx, spec, durable=durable) + ) + if durable: + self._mocking.add(key) + timeout = ( + None if durable else getattr(tool, "timeout_sec", None) + ) or DEFAULT_IMMEDIATE_TIMEOUT_SEC + try: + return await self._await_hosted(mid, key, call_id, task, timeout, tool.name) + finally: + self._mocking.discard(key) + + async def _traced_mock( + self, + tool: Any, + args: dict[str, Any], + ctx: ToolContext | DurableToolContext, + spec: MockSpec, + *, + durable: bool, + ) -> tuple[str, bool]: + """The mocker's value under a ``Tool call`` span; ``(text, is_error)``. + + * ``tool.mock_precheck(args, ctx)``, when the tool has one, runs + first: a :class:`UiPathToolError` from it is the answer, with no + span (as a real ``start`` failing before its span); + * the mocked function runs under the tool's child span + (``tool.start_mock_span(args, ctx)`` → :class:`MockSpan`), renamed + ``Simulated result: `` when mocked, else under the ``Tool + call`` span itself (a custom tool); + * ``tool.mocked_output(args, value, ctx)`` turns the value into the + model's text (a durable tool's ``finish`` shape); without it the + value is dumped as JSON. A :class:`UiPathRunTerminatedError` from it + (an escalation outcome mapped to ``end``) fails the run. + + The spans end with the call: nothing is pushed as running or saved. + """ + generation_error = _mock_generation_error() + precheck = getattr(tool, "mock_precheck", None) + if callable(precheck): + try: + checked = precheck(args, ctx) + if inspect.isawaitable(checked): + await checked + except UiPathToolError as e: + return str(e), True + except Exception as e: + logger.exception("mock precheck of %s failed", tool.name) + return f"Tool '{tool.name}' failed: {str(e) or type(e).__name__}", True + if durable: + kind = str(getattr(tool, "kind", "durable")) + attrs: dict[str, Any] = {"tool.kind": kind} + else: + kind = str(getattr(tool, "kind", None) or "function") + attrs = {"tool.kind": kind, "tool.server": SERVER_NAME} + extra = getattr(tool, "span_attributes", None) + shown = _span_args(tool, args, ctx) + span = start_tool_call( + tool.name, + tool_type=tool_type_of(tool, kind), + call_id=ctx.call_id, + arguments=shown, + tracer=self._tracer, + attachments=span_attachments(input_attachments(tool, shown), "in"), + **{**attrs, **(extra if isinstance(extra, dict) else {})}, + ) + mspan: MockSpan | None = None + + def fail(error: BaseException, text: str | None = None) -> None: + if mspan is not None: + fail_span(mspan.span, error) + mspan.span.end() + if text is None: + fail_span(span, error) + span.end() + else: + end_tool_call( + span, + output=_parsed(text), + content=text, + error=str(error) or type(error).__name__, + ) + + with trace.use_span(span, end_on_exit=False): + try: + hook = getattr(tool, "start_mock_span", None) + mspan = hook(args, ctx) if callable(hook) else None + inner = mspan.span if mspan is not None else span + with trace.use_span(inner, end_on_exit=False): + try: + value = await wrap(spec, _not_mocked(tool))( + **_mock_args(tool, args, ctx) + ) + except generation_error as e: + # A mockito ``raise`` answer or arguments no behavior + # matches, an LLM failure: a tool error (as in + # langchain, where it is raised out of the tool). + cause = e.__cause__ + detail = ( + str(e).strip() + or (str(cause).strip() if cause else "") + or type(cause or e).__name__ + ) + raise UiPathToolError( + f"Tool '{tool.name}' failed: {detail}" + ) from e + if mspan is not None: + mark_simulated(mspan.span) + shaper = getattr(tool, "mocked_output", None) + text = ( + await shaper(args, value, ctx) + if callable(shaper) + else mocked_text(value) + ) + if not isinstance(text, str): + text = json.dumps(text, default=str) + except UiPathToolError as e: + fail(e) + return str(e), True + except asyncio.CancelledError as e: + fail(e) + raise + except UiPathRunTerminatedError as e: + # The simulated result ends the run, as the real one would on + # resume (an escalation outcome mapped to ``end``). + logger.info("simulated tool %s ended the run: %s", tool.name, e) + if mspan is not None: + mspan.span.set_attribute( + mspan.output_attribute, + _json(_only_keys(_parsed(e.result_text), mspan.output_keys)), + ) + fail(e, e.result_text) + if self.fatal_error is None: + self.fatal_error = e + self.fatal.set() + return e.result_text, True + except UiPathRuntimeError as e: + logger.error("simulated tool %s failed the run: %s", tool.name, e) + fail(e) + if self.fatal_error is None: + self.fatal_error = e + self.fatal.set() + return f"Tool '{tool.name}' failed: {e}", True + except Exception as e: + logger.exception("simulated tool %s failed", tool.name) + fail(e) + return f"Tool '{tool.name}' failed: {str(e) or type(e).__name__}", True + parsed = _parsed(text) + harvested = self.attachments.harvest(output_paths(tool), parsed) + if harvested: + merged = merge_span_attachments( + (getattr(span, "attributes", None) or {}).get("attachments"), + span_attachments(harvested, "out"), + ) + if merged: + span.set_attribute("attachments", merged) + if mspan is not None: + mspan.span.set_attribute( + mspan.output_attribute, + _json(_only_keys(parsed, mspan.output_keys)), + ) + mspan.span.set_status(Status(StatusCode.OK)) + mspan.span.end() + end_tool_call(span, output=parsed, content=text) + return text, False + + def _resolve_attachments( + self, tool: Any, params: dict[str, Any] + ) -> tuple[Any, list[str]]: + """The call's arguments with known attachments filled in, and errors. + + The errors are those of the arguments merged with the tool's + pre-filled values (LangChain's order, see the module docstring); the + arguments returned are the model's, with the known attachments filled + in (an unknown one a pre-filled value replaces is left as is). + + A tool with ``checks_prefilled_attachments = False`` (an escalation, + which LangChain never resolves) has only the model's own values + checked, those the pre-filled ones leave in place. + """ + raw_args = params.get("arguments") or {} + if not isinstance(raw_args, dict): + return raw_args, [] + paths = input_paths(tool) + merged = _prefilled_args(tool, raw_args, self._agent_input) + if merged is None: + if not paths: + return raw_args, [] + return self.attachments.resolve(paths, raw_args) + if not getattr(tool, "checks_prefilled_attachments", True): + resolved, own = self.attachments.resolve(paths, raw_args) + _, kept = self.attachments.resolve(paths, merged) + return resolved, [e for e in own if e in kept] + _, errors = self.attachments.resolve(prefilled_paths(tool), merged) + if not paths: + return raw_args, errors + resolved, _ = self.attachments.resolve(paths, raw_args) + return resolved, errors + + def _spawn_in_context(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]: + loop = asyncio.get_running_loop() + if self._context is None: + return loop.create_task(coro) + # A copy per call: one Context object cannot be entered by two tasks. + context = self._context.run(contextvars.copy_context) + return loop.create_task(coro, context=context) + + # --- durable tools ------------------------------------------------------ + + async def _start( + self, tool: UiPathDurableTool, call_id: str, args: dict[str, Any] + ) -> str | None: + """Start the job and record it; return a model-facing error, or None.""" + if self._probe is None or not await self._probe(call_id): + logger.warning( + "durable call %s is not in the rollout; not starting it", call_id + ) + return ( + f"Tool '{tool.name}' could not be started because the call was not " + "recorded. Call it again." + ) + if self._closing: + return _CLOSING_TEXT + assert self._store is not None # durable tools always have one + async with self._lock: + self._order += 1 + order = self._order + pending = PendingCall( + call_id=call_id, + tool=tool.name, + args=args, + state=STATE_STARTING, + order=order, + ) + await self._store.put_pending(pending) + try: + value, span = await self._run_in_context( + self._traced_start( + tool, + args, + DurableToolContext( + call_id=call_id, + runtime_id=self._runtime_id, + tool_name=tool.name, + agent_input=self._agent_input, + attachments=self.attachments, + model=self._model, + results=self.results, + ), + ) + ) + except UiPathDurableToolError as e: + await self._store.remove_pending([call_id]) + return str(e) + except UiPathRuntimeError as e: + # Fails the run (e.g. an IXP extraction without a document). + logger.error("durable tool %s failed the run: %s", tool.name, e) + await self._store.remove_pending([call_id]) + if self.fatal_error is None: + self.fatal_error = e + self.fatal.set() + return f"Tool '{tool.name}' failed: {e}" + except Exception as e: + logger.exception("durable tool %s failed to start", tool.name) + await self._store.remove_pending([call_id]) + return f"Tool '{tool.name}' failed to start: {e}" + + pending.state = STATE_STARTED + pending.value = value + pending.value_type = type(value).__name__ + pending.span = span + try: + await self._store.put_pending(pending) + except Exception: + logger.exception( + "durable call %s started its job but could not be saved; the job " + "is orphaned", + call_id, + ) + raise + self._held[call_id] = HeldCall(call_id, tool.name, value, time.monotonic()) + return None + + async def _traced_start( + self, tool: UiPathDurableTool, args: dict[str, Any], ctx: DurableToolContext + ) -> tuple[Any, dict[str, Any] | None]: + """``tool.start`` under a ``Tool call`` span (the job's ``ParentSpanId``). + + Runs inside the run's context, so the span is a child of the run's + current span (``Agent run``, or ``Resume`` in a resumed execution). + A started job leaves the span open, pushed to LLMOps as running with + the job key; returns the suspend value and the span's saved identity, + which the execution that uses the job's result completes. A start + failure ends the span with the error. + """ + kind = str(getattr(tool, "kind", "durable")) + extra = getattr(tool, "span_attributes", None) + shown = _span_args(tool, args, ctx) + span = start_tool_call( + tool.name, + tool_type=tool_type_of(tool, kind), + call_id=ctx.call_id, + arguments=shown, + tracer=self._tracer, + attachments=span_attachments(input_attachments(tool, shown), "in"), + **{"tool.kind": kind, **(extra if isinstance(extra, dict) else {})}, + ) + with trace.use_span(span, end_on_exit=False): + token = collect_held_spans() + try: + value = await tool.start(args, ctx) + except BaseException as e: + fail_span(span, e) + span.end() + raise + finally: + children = held_spans(token) + set_tool_output(span, _started_output(tool, value)) + push_running(span) + record = span_record(span) + if record is not None and children: + record["children"] = children + keys = getattr(tool, "span_output_keys", None) + if record is not None and keys: + record["output_keys"] = list(keys) + return value, record + + async def _run_in_context(self, coro: Coroutine[Any, Any, Any]) -> Any: + if self._context is None: + return await coro + return await self._spawn_in_context(coro) + + async def _hold(self, mid: Any) -> web.StreamResponse: + """Never answer (until teardown): the Codex turn stays blocked here.""" + await self._release.wait() + return _tool_error(mid, "The run was suspended while this call was pending.") + + +def input_paths(tool: Any) -> list[Path]: + """Attachment fields of a tool's input schema (as the model sees it).""" + try: + return attachment_paths(tool.input_schema) + except Exception: + return [] + + +def prefilled_paths(tool: Any) -> list[Path]: + """Attachment fields of a tool's input, pre-filled ones included. + + Those of ``prefilled_schema`` (the input with its pre-filled fields, which + ``input_schema`` hides) and of ``input_schema``. + """ + paths = input_paths(tool) + try: + schema = getattr(tool, "prefilled_schema", None) + except Exception: + logger.debug("prefilled_schema of %s failed", tool.name, exc_info=True) + schema = None + if not isinstance(schema, dict): + return paths + full = attachment_paths(schema) + return [*full, *(p for p in paths if p not in full)] + + +def _prefilled_args( + tool: Any, args: dict[str, Any], agent_input: dict[str, Any] | None +) -> dict[str, Any] | None: + """``tool.prefilled_args(args, agent_input)``; None without the hook (or on error).""" + hook = getattr(tool, "prefilled_args", None) + if not callable(hook): + return None + try: + merged = hook(args, agent_input) + except Exception: + logger.debug("prefilled_args of %s failed", tool.name, exc_info=True) + return None + return merged if isinstance(merged, dict) else None + + +def output_paths(tool: Any) -> list[Path]: + """Attachment fields of a tool's output schema (``output_json_schema``).""" + schema = getattr(tool, "output_json_schema", None) + return attachment_paths(schema) if isinstance(schema, dict) else [] + + +def input_attachments(tool: Any, args: Any) -> list[Any]: + """The attachments in a call's (resolved, pre-filled) arguments.""" + found = (to_attachment(v) for v in values_at(args, prefilled_paths(tool))) + return [a for a in found if a is not None] + + +def _started_output(tool: Any, value: Any) -> dict[str, Any]: + """The ``Tool call`` output while suspended: the tool's, else the job's.""" + hook = getattr(tool, "started_output", None) + if callable(hook): + try: + out = hook(value) + if isinstance(out, dict): + return out + except Exception: + logger.debug("started_output of %s failed", tool.name, exc_info=True) + job = getattr(value, "job", None) + return { + "status": "started", + "job_key": getattr(job, "key", None), + "job_state": getattr(getattr(job, "state", None), "value", None) + or getattr(job, "state", None), + } + + +class _NoSuchError(Exception): + """Stands in for an exception type that cannot be imported.""" + + +def _mock_generation_error() -> type[Exception]: + """``UiPathMockResponseGenerationError``: the mocker could not answer.""" + try: + from uipath.eval.mocks._mocker import UiPathMockResponseGenerationError + except Exception: # pragma: no cover - eval extras missing + return _NoSuchError + return UiPathMockResponseGenerationError + + +def _span_args(tool: Any, args: dict[str, Any], ctx: Any) -> Any: + """The ``Tool call`` span's input: the tool's ``span_args`` hook, else the model's. + + A tool with hidden pre-filled fields shows them merged in, as low-code's + tool call does (values marked sensitive included, as there). + """ + hook = getattr(tool, "span_args", None) + if not callable(hook): + return args + try: + return hook(args, ctx) + except Exception: + logger.debug("span_args of %s failed", tool.name, exc_info=True) + return args + + +def _mock_args(tool: Any, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The arguments the mocker sees: the tool's ``mock_args`` hook, else the model's. + + A tool with hidden pre-filled fields merges them in, as langchain's + ``StaticArgsHandler`` does before the mocked function runs. + """ + hook = getattr(tool, "mock_args", None) + return hook(args, ctx) if callable(hook) else args + + +def _not_mocked(tool: Any) -> Callable[..., Awaitable[Any]]: + """The function a simulated call wraps: reached only if the mocker declines.""" + + async def not_mocked(**_kwargs: Any): # no return annotation: kept as is + raise UiPathToolError( + f"Tool '{tool.name}' was selected for simulation but no simulated " + "result was produced." + ) + + return not_mocked + + +_CLOSING_TEXT = ( + "The run is being suspended; this call was not started. Call it again if " + "it is still needed." +) + + +# --- JSON-RPC helpers ------------------------------------------------------- + + +def _rpc_result(mid: Any, result: dict[str, Any]) -> web.Response: + return web.json_response({"jsonrpc": "2.0", "id": mid, "result": result}) + + +def _rpc_error(mid: Any, code: int, message: str, *, status: int = 200) -> web.Response: + return web.json_response( + {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}, + status=status, + ) + + +def _parsed(text: str) -> Any: + try: + return json.loads(text) + except ValueError: + return text + + +def _tool_result(mid: Any, text: str) -> web.Response: + return _rpc_result( + mid, {"content": [{"type": "text", "text": text}], "isError": False} + ) + + +def _tool_error(mid: Any, text: str) -> web.Response: + return _rpc_result( + mid, {"content": [{"type": "text", "text": text}], "isError": True} + ) + + +__all__ = [ + "DEFAULT_IMMEDIATE_TIMEOUT_SEC", + "input_paths", + "output_paths", + "prefilled_paths", + "DurableToolHost", + "HeldCall", + "LONG_RUNNING_TAG", + "SERVER_NAME", + "TOKEN_ENV", + "UNKNOWN_OUTCOME_OUTPUT", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/triggers.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/triggers.py new file mode 100644 index 00000000..56bb1aa5 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/triggers.py @@ -0,0 +1,67 @@ +"""The resume trigger handler codex runs use.""" + +from __future__ import annotations + +from typing import Any + +from uipath.core.triggers import UiPathResumeTriggerType +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler + +_IXP_TYPES = ( + UiPathResumeTriggerType.IXP_EXTRACTION, + UiPathResumeTriggerType.IXP_VS_ESCALATION, +) + + +class CodexResumeTriggerHandler(UiPathResumeTriggerHandler): + """``UiPathResumeTriggerHandler`` that reads codex's own triggers. + + A Batch Transform trigger of ``WaitBatchTransformFile`` is read by + :func:`~uipath_codex_sdk.batch_transform_tools.read_batch_transform_trigger` + (status only; the tool downloads the result); every other trigger as the + platform reads it. + """ + + async def read_trigger(self, trigger: Any) -> Any | None: + from ..batch_transform_tools import ( + is_codex_batch_transform_trigger, + read_batch_transform_trigger, + ) + + if is_codex_batch_transform_trigger(trigger): + return await read_batch_transform_trigger(trigger) + if getattr(trigger, "trigger_type", None) in _IXP_TYPES: + return await self._read_ixp(trigger) + return await super().read_trigger(trigger) + + async def _read_ixp(self, trigger: Any) -> Any | None: + """The platform's IXP reading, with its failures as faulted triggers. + + A failed extraction or validation operation, or a validation task + with no status (deleted), fails the run with a clear message instead + of an unexpected exception. + """ + from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError + from uipath.platform.errors import OperationFailedException + + kind = ( + "IXP extraction" + if trigger.trigger_type == UiPathResumeTriggerType.IXP_EXTRACTION + else "Document validation" + ) + try: + return await super().read_trigger(trigger) + except OperationFailedException as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"{kind} '{trigger.item_key}' failed: {getattr(e, 'message', e)}", + ) from e + except KeyError as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"{kind} task '{trigger.item_key}' has no status; it may have been " + "deleted.", + ) from e + + +__all__ = ["CodexResumeTriggerHandler"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/tool_results.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/tool_results.py new file mode 100644 index 00000000..8eac06e6 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/tool_results.py @@ -0,0 +1,62 @@ +"""Results one durable tool keeps for another within a run. + +The codex counterpart of langchain's ``inner_state.tools_storage``: an IXP +extraction keeps its full extraction response under its name, and the +Document Validation escalation reads it back. Saved with a suspended run, so +the two calls may be days and robots apart. +""" + +from __future__ import annotations + +from typing import Any + + +class ToolResultStore: + """Values by key (last write wins) plus the keys whose result is on its way.""" + + def __init__( + self, + values: dict[str, Any] | None = None, + pending: list[str] | None = None, + ) -> None: + self._values: dict[str, Any] = dict(values or {}) + self._pending: set[str] = set(pending or []) + self.changed = False + + def get(self, key: str) -> Any | None: + return self._values.get(key) + + def put(self, key: str, value: Any) -> None: + self._values[key] = value + self._pending.discard(key) + self.changed = True + + def mark_pending(self, key: str) -> None: + """A call that will produce ``key``'s result has started.""" + self._pending.add(key) + self.changed = True + + def clear_pending(self, key: str) -> None: + if key in self._pending: + self._pending.discard(key) + self.changed = True + + def is_pending(self, key: str) -> bool: + return key in self._pending + + def to_json(self) -> dict[str, Any]: + return {"values": self._values, "pending": sorted(self._pending)} + + @classmethod + def from_json(cls, data: Any) -> "ToolResultStore": + if not isinstance(data, dict): + return cls() + values = data.get("values") + pending = data.get("pending") + return cls( + values if isinstance(values, dict) else None, + pending if isinstance(pending, list) else None, + ) + + +__all__ = ["ToolResultStore"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/tools.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/tools.py new file mode 100644 index 00000000..673c3422 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/tools.py @@ -0,0 +1,963 @@ +"""UiPath tools served to Codex by the runtime's own MCP endpoint. + +Two kinds share the ``uipath_tools`` server: + +* **durable** tools (:class:`UiPathDurableTool`) suspend the run on an + Orchestrator job (below); +* **immediate** tools (:class:`UiPathTool`) answer the call as soon as their + work is done, e.g. Integration Service activities + (:func:`~uipath_codex_sdk.uipath_integration_tool`). + +Durable tools: Codex tool calls that suspend the run on an Orchestrator job. + +A durable tool is served to Codex by the runtime's own MCP endpoint. When the +model calls one, the runtime starts the job (:meth:`UiPathDurableTool.start`), +never answers the call, hard-stops Codex and returns SUSPENDED with the job as +the resume trigger. When the job finishes, the run resumes, possibly on +another robot, and :meth:`UiPathDurableTool.finish` turns the finished job into +the text the model receives as the result of its original call. + +Typical use:: + + from pydantic import BaseModel + from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool + + class AddIn(BaseModel): + a: int + b: int + + agent = UiPathCodexAgent( + model="gpt-5.6-terra", + prompt="Use a_plus_b to add {a} and {b}.", + tools=[uipath_process_tool("a_plus_b", input_schema=AddIn, + description="Adds a and b.")], + ) +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Mapping, + Protocol, + Union, + runtime_checkable, +) + +from pydantic import BaseModel, ValidationError + +from ._fixed_args import ( + FixedArg, + _segments, + fixed_args_from_code, + fixed_args_schema, + missing_args_message, + reachable, + with_fixed_args, +) +from .attachments import AttachmentStore, attachment_paths, to_attachment, values_at +from .tool_results import ToolResultStore + +if TYPE_CHECKING: + from .runtime.mocking import MockSpan, MockSpec + +logger = logging.getLogger(__name__) + +__all__ = [ + "DurableToolContext", + "ToolContext", + "AnyUiPathTool", + "UiPathDurableTool", + "UiPathDurableToolError", + "UiPathDurableToolPending", + "UiPathProcessTool", + "UiPathTool", + "UiPathToolError", + "is_durable_tool", + "is_immediate_tool", + "uipath_agent_tool", + "uipath_process_tool", +] + +#: Job states that are still on their way to a final state — the same set +#: ``UiPathResumeTriggerHandler.read_trigger`` keeps waiting on. Stopping and +#: Terminating are *not* in it: the handler reports them as fired, so treating +#: them as pending would make the in-process resume loop re-poll Orchestrator +#: until the child leaves that state (possibly never). They are answered as +#: final ``{"status": "stopping"|"terminating"}`` results instead. +_PENDING_STATES = frozenset({"pending", "running", "suspended", "resumed"}) + +_TOOL_NAME_RE = re.compile(r"[^A-Za-z0-9_-]") +_TOOL_NAME_MAX = 64 + +_PERMISSIVE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {}, + "additionalProperties": True, +} + +_START_404_TEMPLATES: dict[str, str] = { + "AssociatedProcessNotFound": ( + "Could not find process for tool '{tool}'. Please check if the process " + "is deployed in the configured folder." + ), + "AttachmentNotFound": ( + "Could not find an attachment passed to tool '{tool}'. Please check that " + "the attachments provided to the tool still exist." + ), +} +_START_404_FALLBACK = ( + "Could not start process for tool '{tool}': an item required to start the " + "job was not found. Server message: {message}" +) + + +class UiPathToolError(Exception): + """A tool could not do its work; the message is shown to the model.""" + + +class UiPathDurableToolError(UiPathToolError): + """A durable tool could not do its work; the message is shown to the model.""" + + +class UiPathDurableToolPending(Exception): + """The resume value says the job has not reached a final state yet. + + Raised by :meth:`UiPathDurableTool.finish`; the runtime then suspends again + on the same job instead of answering the call. Only raise it for states the + resume trigger handler also treats as pending: a value the handler reports + as fired is read again straight away by the in-process resume loop. + """ + + +@dataclass(frozen=True) +class DurableToolContext: + """What a durable tool knows about the call it is starting. + + Attributes: + call_id: The Codex/Responses ``call_id`` of the tool call. It is also + the interrupt id of the resume trigger. + runtime_id: The UiPath runtime id (the job key when deployed). + tool_name: The name the model called the tool by. + agent_input: The validated input of this run (a dict), used to resolve + ``{{arg}}`` values; None when unknown. + attachments: The job attachments the run knows. Attachment arguments + arrive already resolved to full JobAttachment objects. + model: The agent's model. + results: Results durable tools keep for each other in the run (an + IXP extraction's, for its Document Validation tool). + """ + + call_id: str + runtime_id: str + tool_name: str + agent_input: dict[str, Any] | None = None + attachments: AttachmentStore | None = None + model: str | None = None + results: ToolResultStore | None = None + + +@runtime_checkable +class UiPathDurableTool(Protocol): + """A tool whose call suspends the run until an external operation ends. + + ``start`` runs once, when the model calls the tool. ``finish`` runs once, + on resume, against the arguments saved at start; unlike an in-tool + interrupt the tool body never runs twice. + + Optional attributes, read with ``getattr``: ``kind`` / ``tool_type`` (as + for :class:`UiPathTool`), ``started_output(value) -> dict``, the + ``Tool call`` span's output while the call is suspended (default: the + job key and state), and ``span_args(args, ctx)``, the ``Tool call`` + span's input (default: the model's arguments). ``start`` may leave child + spans open across the suspension with + :func:`~uipath_codex_sdk.runtime.telemetry.hold_span`. + + Pre-filled arguments (optional, for a tool that merges values the model + does not fill over its arguments): ``prefilled_args(args, agent_input)``, + the model's arguments with those values merged in (unvalidated), and + ``prefilled_schema``, the input schema they follow (pre-filled fields + included). The host checks the job attachments of the merged arguments, + as LangChain does once its static arguments are applied, so a model value + a pre-filled one replaces is not checked; ``start`` / ``call`` still get + the model's arguments. ``checks_prefilled_attachments = False`` has the + host check only the model's own values the merge keeps, never a + pre-filled one (an escalation, which LangChain never resolves). + + Eval mocking (all optional): ``mock_spec`` (a + :class:`~uipath_codex_sdk.runtime.mocking.MockSpec`: the mock name, + description, schemas and example calls; default from ``mock_name`` / + ``display_name`` / ``name``, ``output_json_schema`` and + ``example_calls``), ``mock_precheck(args, ctx)`` (raise + :class:`UiPathToolError` to refuse a simulated call), + ``start_mock_span(args, ctx)`` (the child span a simulated call runs + under), ``mock_args(args, ctx)`` (the arguments the mocker sees; default: + the model's) and ``async mocked_output(args, value, ctx) -> str`` (the + mocked value as the model's text; default: the value as JSON). + """ + + #: Name the model calls the tool by (``[A-Za-z0-9_-]``, at most 64 chars). + name: str + #: Description shown to the model. + description: str + + @property + def input_schema(self) -> dict[str, Any]: + """JSON schema of the tool arguments (an ``object`` schema).""" + ... + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + """Validate and normalise the model's arguments; raise ``ValueError``.""" + ... + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Start the operation and return the suspend value (e.g. ``WaitJobRaw``).""" + ... + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """Turn the resume value into the tool result text for the model.""" + ... + + +@dataclass(frozen=True) +class ToolContext: + """What an immediate tool knows about the call it is answering. + + Attributes: + call_id: The Codex/Responses ``call_id`` of the tool call, or None + when the MCP request carried none. + runtime_id: The UiPath runtime id (the job key when deployed). + tool_name: The name the model called the tool by. + agent_input: The validated input of this run (a dict), used to resolve + ``{{arg}}`` parameters; None when unknown. + attachments: The job attachments the run knows. Attachment arguments + arrive already resolved to full JobAttachment objects. + model: The agent's model (e.g. for a tool that calls the LLM itself). + """ + + call_id: str | None + runtime_id: str + tool_name: str + agent_input: dict[str, Any] | None = None + attachments: AttachmentStore | None = None + model: str | None = None + + +@runtime_checkable +class UiPathTool(Protocol): + """A tool the runtime answers as soon as :meth:`call` returns. + + Unlike a durable tool, the call is never held and the run never suspends + on it. Optional attributes, read with ``getattr``: + + * ``kind`` (``str``, default ``"function"``): the span ``toolType`` + (capitalized) and the ``tool.kind`` attribute; + * ``tool_type`` (``str``): the span ``toolType`` as is, instead of the + capitalized ``kind`` (``ContextGrounding``); + * ``timeout_sec`` (``float | None``): host-enforced timeout in seconds; + None (or missing) means 300, there is no way to turn it off; + * ``span_attributes`` (``dict``): extra attributes on the ``Tool call`` span; + * the eval mocking hooks of :class:`UiPathDurableTool`, or + ``mocks_in_call = True`` for a tool that mocks its own platform call + inside ``call`` (the host then never simulates it); + * ``prefilled_args`` / ``prefilled_schema``, as for + :class:`UiPathDurableTool`. + """ + + #: Name the model calls the tool by (``[A-Za-z0-9_-]``, at most 64 chars). + name: str + #: Description shown to the model. + description: str + + @property + def input_schema(self) -> dict[str, Any]: + """JSON schema of the tool arguments (an ``object`` schema).""" + ... + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + """Validate and normalise the model's arguments; raise ``ValueError``.""" + ... + + async def call(self, args: dict[str, Any], ctx: ToolContext) -> str: + """Do the work and return the result text; raise :class:`UiPathToolError`.""" + ... + + +#: What ``UiPathCodexAgent.tools`` holds: durable or immediate tools. +AnyUiPathTool = Union[UiPathDurableTool, UiPathTool] + + +def tool_type_of(tool: Any, kind: str | None = None) -> str: + """``toolType`` of a tool's ``Tool call`` span. + + The tool's ``tool_type`` attribute when set (``ContextGrounding``), else + its ``kind`` capitalized (``integration`` → ``Integration``). + """ + explicit = getattr(tool, "tool_type", None) + if explicit: + return str(explicit) + return str(kind or getattr(tool, "kind", None) or "function").capitalize() + + +def is_durable_tool(tool: Any) -> bool: + """True for a tool that suspends the run (:class:`UiPathDurableTool`).""" + return isinstance(tool, UiPathDurableTool) + + +def is_immediate_tool(tool: Any) -> bool: + """True for a tool answered right away (:class:`UiPathTool`, not durable).""" + return isinstance(tool, UiPathTool) and not is_durable_tool(tool) + + +def sanitize_tool_name(name: str) -> str: + """Make ``name`` acceptable as a model-facing tool name.""" + cleaned = _TOOL_NAME_RE.sub("_", name.strip()) or "tool" + return cleaned[:_TOOL_NAME_MAX] + + +def _schema_from( + input_schema: type[BaseModel] | dict[str, Any] | None, +) -> dict[str, Any]: + if input_schema is None: + return dict(_PERMISSIVE_SCHEMA) + if isinstance(input_schema, dict): + return dict(input_schema) + return _tool_schema_from_model(input_schema) + + +def _tool_schema_from_model(model_type: type[BaseModel]) -> dict[str, Any]: + """A tool's argument schema: ``$ref`` resolved, nullable ``anyOf`` collapsed. + + Unlike the entrypoint schema (``_model_to_platform_schema``), collapsing + ``anyOf: [X, null]`` keeps the field's own ``description``, ``title`` and + non-null ``default`` (``num: int | None = Field(None, description=...)``), so the + model still sees them. A ``UiPathAttachment`` field becomes the + ``job-attachment`` reference, so the model only has to pass its ``ID``. + """ + from uipath.runtime.schema import transform_attachments, transform_references + + raw = model_type.model_json_schema() + unpacked, _ = transform_references(raw) + result: dict[str, Any] = { + "type": "object", + "properties": _collapse_nullable(unpacked.get("properties", {})), + "required": unpacked.get("required", []), + } + for key in ("title", "description"): + if key in unpacked: + result[key] = unpacked[key] + return transform_attachments(result) + + +def _collapse_nullable(schema: Any) -> Any: + if isinstance(schema, dict): + branches = schema.get("anyOf") + if isinstance(branches, list) and len(branches) == 2: + non_null = [ + b + for b in branches + if not (isinstance(b, dict) and b.get("type") == "null") + ] + if len(non_null) == 1 and isinstance(non_null[0], dict): + merged = {k: v for k, v in schema.items() if k != "anyOf"} + merged.update(non_null[0]) + for key in ("description", "title"): + if key in schema: + merged[key] = schema[key] + if merged.get("default", 0) is None: + merged.pop("default") # null is not a value of the type + return _collapse_nullable(merged) + return {k: _collapse_nullable(v) for k, v in schema.items()} + if isinstance(schema, list): + return [_collapse_nullable(item) for item in schema] + return schema + + +_JSON_TYPES: dict[str, tuple[type, ...]] = { + "string": (str,), + "integer": (int,), + "number": (int, float), + "boolean": (bool,), + "object": (dict,), + "array": (list,), + "null": (type(None),), +} + + +def _check_json_type(value: Any, expected: Any) -> bool: + types = expected if isinstance(expected, list) else [expected] + for t in types: + python_types = _JSON_TYPES.get(t) + if python_types is None: + return True # unknown type keyword: do not reject + if isinstance(value, bool) and t in ("integer", "number"): + continue + if isinstance(value, python_types): + return True + return False + + +def _validate_against_schema(schema: dict[str, Any], args: dict[str, Any]) -> None: + """Shallow JSON-schema check: required keys and top-level property types.""" + missing = [k for k in schema.get("required") or [] if k not in args] + if missing: + raise ValueError(f"missing required argument(s): {', '.join(missing)}") + props = schema.get("properties") or {} + for key, value in args.items(): + spec = props.get(key) + if spec is None: + if schema.get("additionalProperties") is False: + raise ValueError(f"unexpected argument: {key}") + continue + if isinstance(spec, dict) and "type" in spec: + if not _check_json_type(value, spec["type"]): + raise ValueError(f"argument {key!r} must be of type {spec['type']}") + + +def _start_error_message(e: Exception, tool: str) -> str: + """Model-facing text for a failed job start (langchain's mapping).""" + status = getattr(e, "status_code", None) + info = getattr(e, "error_info", None) + code = getattr(info, "error_code", None) if info is not None else None + message = (getattr(info, "message", None) if info is not None else None) or "" + if status == 404 and str(code) == "1002": + template = _START_404_TEMPLATES.get(message, _START_404_FALLBACK) + elif status == 400 and str(code) == "1100": + template = ( + "Could not find folder for tool '{tool}'. Please check if the folder " + "exists and is accessible by the robot." + ) + elif status == 409: + template = "Cannot start process for tool '{tool}': {message}" + else: + template = "Failed to start the job for tool '{tool}': {message}" + message = message or str(e) + return template.format(tool=tool, message=message) + + +def _uipath_client() -> Any: + """The UiPath platform client (a seam for tests).""" + from uipath.platform import UiPath + + return UiPath() + + +def _current_span_id() -> str | None: + """Hex id of the current span (the tool call span), or None.""" + from opentelemetry import trace + + context = trace.get_current_span().get_span_context() + if not context.is_valid: + return None + return format(context.span_id, "016x") + + +def _job_from(value: Any) -> Any | None: + """Return ``value`` as a platform ``Job`` when it is (or serializes) one.""" + from uipath.platform.orchestrator import Job + + if isinstance(value, Job): + return value + if isinstance(value, dict): + keys = set(value) + if {"Key", "State"} <= keys or {"key", "state", "id"} <= keys: + try: + return Job.model_validate(value) + except ValidationError: + return None + return None + + +def _dumps(value: Any) -> str: + if isinstance(value, str): + return value + try: + return json.dumps(value, default=str) + except (TypeError, ValueError): + return str(value) + + +@dataclass +class UiPathProcessTool: + """A durable tool that runs an Orchestrator process (or agent) as a job. + + Prefer the :func:`uipath_process_tool` / :func:`uipath_agent_tool` + factories over constructing this directly. + + Attributes: + process_name: Release name of the process in Orchestrator. It is also + the binding key (``process.``) resource overwrites + are matched on. + name: Model-facing tool name. + description: Description shown to the model. + folder_path: Orchestrator folder of the release. Falls back to + ``UIPATH_FOLDER_PATH``, then to the SDK's configured folder. + input_model: Pydantic model of the process input, when given. + input_json_schema: JSON schema of the process input. Its attachment + fields are linked to the child job as job attachments. + output_json_schema: JSON schema of the process output: shown in the + description, and the job attachments of its attachment fields + become known to the run. + run_as_me: Run the child job under the calling user's identity. + kind: ``"process"`` or ``"agent"`` (agents are releases too). + mock_name: Name the tool is simulated by in evals; None means + ``process_name``. + example_calls: Example calls for the eval LLM mocker. + fixed_args: Pre-filled inputs by path (``a.b``, ``x[*].y``): static + ones are hidden from the model, agent-input ones stay visible but + optional; merged over the model's arguments when the job starts. + tool_type: ``toolType`` of the ``Tool call`` span; None means the + capitalized ``kind`` (``Process`` / ``Agent``). + display_name: Name of a simulated call's ``processTool`` / + ``agentTool`` span; None means ``process_name``. + mock_child_span: Whether a simulated call has that span (low-code + opens none for a Flow). + """ + + process_name: str + name: str + description: str + folder_path: str | None = None + input_model: type[BaseModel] | None = None + input_json_schema: dict[str, Any] = field( + default_factory=lambda: dict(_PERMISSIVE_SCHEMA) + ) + output_json_schema: dict[str, Any] | None = None + run_as_me: bool = False + kind: Literal["process", "agent"] = "process" + mock_name: str | None = None + example_calls: list[Any] = field(default_factory=list) + fixed_args: dict[str, FixedArg] = field(default_factory=dict) + tool_type: str | None = None + display_name: str | None = None + mock_child_span: bool = True + + @property + def input_schema(self) -> dict[str, Any]: + """The process input as the model sees it (pre-filled fields hidden).""" + return fixed_args_schema(self.input_json_schema, self.fixed_args) + + def merged_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The job's input: the model's arguments with the pre-filled values. + + Validated with ``input_model`` once merged, when there is one; else + its required fields are checked (an agent-input field the input has + no value for is left to the model, as in langchain). + + Raises: + UiPathDurableToolError: The merged input does not validate. + """ + if not self.fixed_args: + return dict(args) + merged = with_fixed_args(args, self.fixed_args, agent_input) + if self.input_model is None: + missing = [ + k + for k in self.input_json_schema.get("required") or [] + if k not in merged + ] + if missing: + raise UiPathDurableToolError(missing_args_message(self.name, missing)) + return merged + try: + return self.input_model.model_validate(merged).model_dump( + mode="json", by_alias=True + ) + except ValidationError as e: + raise UiPathDurableToolError( + f"Invalid arguments for tool '{self.name}': {e}" + ) from e + + @property + def prefilled_schema(self) -> dict[str, Any]: + """The process input, pre-filled fields included (the host resolves its attachments).""" + return self.input_json_schema + + def prefilled_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + """The model's arguments with the pre-filled values, unvalidated. + + The host checks the job attachments of these (LangChain's order). + """ + if not self.fixed_args: + return args + return with_fixed_args(args, self.fixed_args, agent_input) + + def span_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The ``Tool call`` span's input: the arguments with the pre-filled values. + + As low-code's, whose tool call carries them once merged (values + marked sensitive included): the job input (:meth:`merged_args`), or + the unvalidated merge when that input does not validate. + """ + if not self.fixed_args: + return args + agent_input = getattr(ctx, "agent_input", None) + try: + return self.merged_args(args, agent_input) + except UiPathToolError: + return with_fixed_args(args, self.fixed_args, agent_input) + + # --- eval mocking ----------------------------------------------------- + + @property + def mock_spec(self) -> MockSpec: + """Simulated as LangChain's process tool: the job's output schema.""" + from .runtime.mocking import ANY_OBJECT_SCHEMA, MockSpec + + return MockSpec( + name=self.mock_name or self.process_name, + description=self.description, + input_schema=self.input_schema, + output_schema=self.output_json_schema or dict(ANY_OBJECT_SCHEMA), + example_calls=list(self.example_calls), + ) + + def start_mock_span(self, args: dict[str, Any], ctx: Any) -> MockSpan | None: + """low-code's ``processTool`` / ``agentTool`` child of a simulated call. + + Its ``arguments`` are the job input the call would start with. None + (no child) when ``mock_child_span`` is off. + """ + from .runtime.mocking import MockSpan + from .runtime.telemetry import start_process_tool_span + + if not self.mock_child_span: + return None + span = start_process_tool_span( + self.display_name or self.process_name, + arguments=self.merged_args(args, getattr(ctx, "agent_input", None)), + kind=self.kind, + ) + return MockSpan(span) + + def mock_args(self, args: dict[str, Any], ctx: Any) -> dict[str, Any]: + """The model's arguments with the pre-filled values, as the mocker sees them. + + langchain's ``StaticArgsHandler`` merges them into the tool call + before its mocked function runs, so mockito behaviors match on them. + """ + return self.merged_args(args, getattr(ctx, "agent_input", None)) + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + """The simulated job output, as a successful job's (``finish``).""" + return _dumps(value) + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args, dict): + raise ValueError("arguments must be a JSON object") + if self.fixed_args: + # The merged input is what the job gets (``merged_args``); here + # the pre-filled fields only must not fail the model's arguments. + if self.input_model is None: + _validate_against_schema(self.input_schema, args) + else: + self._check_with_fixed(args) + return dict(args) + if self.input_model is not None: + try: + return self.input_model.model_validate(args).model_dump( + mode="json", by_alias=True + ) + except ValidationError as e: + raise ValueError(str(e)) from e + _validate_against_schema(self.input_json_schema, args) + return dict(args) + + def _check_with_fixed(self, args: dict[str, Any]) -> None: + """Validate ``args`` with ``input_model``, the pre-filled values set. + + Agent-input fields are not known yet: a missing one is not an error, + nor is an invalid model attachment in one (the host leaves an unknown + one the agent input replaces unresolved); the agent input or, failing + that, ``merged_args`` decides. Any other invalid model value is. + """ + assert self.input_model is not None + try: + self.input_model.model_validate( + with_fixed_args(args, self.fixed_args, None) + ) + except ValidationError as e: + pending = [ + _segments(name) for name, f in self.fixed_args.items() if f.from_input + ] + files: list[list[str | int]] = [ + list(p) for p in attachment_paths(self.input_json_schema) + ] + + def excused(err: Any) -> bool: + loc = tuple(err.get("loc", ())) + if not any(_under(loc, segs) for segs in pending): + return False + return err.get("type") == "missing" or any( + _under(loc, segs) for segs in files + ) + + if not all(excused(err) for err in e.errors()): + raise ValueError(str(e)) from e + + def _folder_path(self) -> str | None: + return self.folder_path or os.environ.get("UIPATH_FOLDER_PATH") or None + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + """Start the job and return ``WaitJobRaw`` for it. + + Only ``process_folder_key`` is set on the wait model (the folder path is + a fallback for a job without a folder key): setting both makes every + resume's job lookup fail. + + The job's ``ParentSpanId`` is the ``Tool call`` span the tool host opens + around this call (passed explicitly: ``invoke_async`` would otherwise + use its own ``processes_invoke`` span). The attachments in the + arguments' attachment fields are linked to the job, as in LangChain. + The job's input is :meth:`merged_args` (``args`` is not changed: the + host keeps it for ``finish``). BTS correlation (``parent_operation_id``) is not passed: it comes from + the low-code agent runtime, which coded Codex agents do not run under. + """ + from uipath.platform.common import WaitJobRaw + + merged = self.merged_args(args, ctx.agent_input) + attachments = [ + a + for a in ( + to_attachment(v) + for v in values_at(merged, attachment_paths(self.input_json_schema)) + ) + if a is not None + ] + extra: dict[str, Any] = {"attachments": attachments} if attachments else {} + client = _uipath_client() + try: + job = await client.processes.invoke_async( + name=self.process_name, + input_arguments=merged, + folder_path=self._folder_path(), + run_as_me=True if self.run_as_me else None, + parent_span_id=_current_span_id(), + **extra, + ) + except Exception as e: + raise UiPathDurableToolError(_start_error_message(e, self.name)) from e + if job is None or not getattr(job, "key", None): + raise UiPathDurableToolError( + f"Orchestrator did not return a job for tool '{self.name}'." + ) + logger.info( + "durable tool %s started %s job %s (call %s)", + self.name, + self.kind, + job.key, + ctx.call_id, + ) + if job.folder_key: + return WaitJobRaw(job=job, process_folder_key=job.folder_key) + return WaitJobRaw(job=job, process_folder_path=self._folder_path()) + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + """Map a finished job (or a raw resume payload) to the tool result text. + + * successful job → its output arguments (JSON text); + * faulted job → ``{"status": "faulted", "error": , "job_key": ...}`` + (plus ``details`` with the structured job error, when there is one); + * stopped, Stopping, Terminating or another final state → + ``{"status": , "job_key": ...}``; + * still pending or running → :class:`UiPathDurableToolPending`; + * anything else (a raw ``--resume`` payload) → JSON-dumped as is. + """ + job = _job_from(resume_value) + if job is None: + return _dumps(resume_value) + state = (job.state or "").lower() + if state in _PENDING_STATES: + raise UiPathDurableToolPending(f"job {job.key} is {state or 'unknown'}") + if state == "successful": + output = await _uipath_client().jobs.extract_output_async(job) + if output is None or output == "": + return _dumps({"status": "successful", "job_key": job.key}) + return _dumps(output) + if state == "faulted": + details = ( + job.job_error.model_dump(mode="json", exclude_none=True) + if job.job_error is not None + else None + ) + fallback = (details or {}).get("detail") or (details or {}).get("title") + result: dict[str, Any] = { + "status": "faulted", + "error": job.info or fallback or "Unknown error", + "job_key": job.key, + } + if details: + result["details"] = details + return _dumps(result) + return _dumps({"status": state or "unknown", "job_key": job.key}) + + +def _under(loc: tuple[Any, ...], segs: list[str | int]) -> bool: + """True when a validation error's ``loc`` is at or under path ``segs``.""" + if len(loc) < len(segs): + return False + return all( + seg == part or (seg == "*" and isinstance(part, int)) + for seg, part in zip(segs, loc[: len(segs)], strict=True) + ) + + +def _description( + description: str | None, + process_name: str, + kind: str, + output_schema: dict[str, Any] | None, +) -> str: + text = ( + description + or f"Runs the UiPath {kind} '{process_name}' and returns its output." + ) + if output_schema and output_schema.get("properties"): + text += " Returns JSON matching: " + json.dumps( + {"type": "object", "properties": output_schema["properties"]}, + separators=(",", ":"), + ) + return text + + +def uipath_process_tool( + name: str, + *, + folder_path: str | None = None, + description: str | None = None, + input_schema: type[BaseModel] | dict[str, Any] | None = None, + output_schema: type[BaseModel] | dict[str, Any] | None = None, + tool_name: str | None = None, + run_as_me: bool = False, + example_calls: list[Any] | None = None, + static_args: Mapping[str, Any] | None = None, + args_from_input: Mapping[str, str] | None = None, +) -> UiPathProcessTool: + """Declare an Orchestrator process as a durable tool. + + Args: + name: Release name of the process (e.g. ``"a_plus_b"``). + folder_path: Orchestrator folder holding the release. Defaults to the + run's folder (``UIPATH_FOLDER_PATH``). + description: What the tool does, for the model. + input_schema: Pydantic model or JSON schema of the process input + arguments. Arguments are validated against it before the job starts. + output_schema: Pydantic model or JSON schema of the process output, + added to the description so the model knows what comes back. + tool_name: Model-facing tool name; defaults to the sanitized ``name``. + run_as_me: Run the child job under the calling user's identity. + example_calls: Example calls (``uipath.eval.mocks.ExampleCall``) the + eval LLM mocker is shown when it simulates the tool (mock name: + ``name``). + static_args: Fixed process inputs, hidden from the model. Keys are + paths into the input (``a.b``, ``items[*].id``; JSONPath such as + ``$['a']['b']`` too). + args_from_input: Process inputs read from the agent input (field → + path, ``{{a.b}}`` / ``a.b`` / ``$.a.b``). The field stays visible + to the model (optional), whose value is used when the agent input + has none. + + Raises: + ValueError: A field in both ``static_args`` and ``args_from_input``, + or one the input schema does not have. + """ + return _build_tool( + name, + kind="process", + folder_path=folder_path, + description=description, + input_schema=input_schema, + output_schema=output_schema, + tool_name=tool_name, + run_as_me=run_as_me, + example_calls=example_calls, + static_args=static_args, + args_from_input=args_from_input, + ) + + +def uipath_agent_tool( + name: str, + *, + folder_path: str | None = None, + description: str | None = None, + input_schema: type[BaseModel] | dict[str, Any] | None = None, + output_schema: type[BaseModel] | dict[str, Any] | None = None, + tool_name: str | None = None, + run_as_me: bool = False, + example_calls: list[Any] | None = None, + static_args: Mapping[str, Any] | None = None, + args_from_input: Mapping[str, str] | None = None, +) -> UiPathProcessTool: + """Declare a deployed UiPath agent as a durable tool. + + Agents are Orchestrator releases, so this is :func:`uipath_process_tool` + with ``kind="agent"``; see it for the arguments. + """ + return _build_tool( + name, + kind="agent", + folder_path=folder_path, + description=description, + input_schema=input_schema, + output_schema=output_schema, + tool_name=tool_name, + run_as_me=run_as_me, + example_calls=example_calls, + static_args=static_args, + args_from_input=args_from_input, + ) + + +def _build_tool( + name: str, + *, + kind: Literal["process", "agent"], + folder_path: str | None, + description: str | None, + input_schema: type[BaseModel] | dict[str, Any] | None, + output_schema: type[BaseModel] | dict[str, Any] | None, + tool_name: str | None, + run_as_me: bool, + example_calls: list[Any] | None = None, + static_args: Mapping[str, Any] | None = None, + args_from_input: Mapping[str, str] | None = None, +) -> UiPathProcessTool: + tool_label = sanitize_tool_name(tool_name or name) + input_model = ( + input_schema + if isinstance(input_schema, type) and issubclass(input_schema, BaseModel) + else None + ) + output_json = _schema_from(output_schema) if output_schema is not None else None + input_json = _schema_from(input_schema) + label = f"{kind.capitalize()} tool '{tool_label}'" + fixed = fixed_args_from_code(static_args, args_from_input, label=label) + if input_json.get("properties"): + for field_name in fixed: + if not reachable(input_json, field_name): + raise ValueError(f"{label}: '{field_name}' is not in its input schema.") + return UiPathProcessTool( + process_name=name, + name=tool_label, + description=_description(description, name, kind, output_json), + folder_path=folder_path, + input_model=input_model, + input_json_schema=input_json, + output_json_schema=output_json, + run_as_me=run_as_me, + kind=kind, + example_calls=list(example_calls or []), + fixed_args=fixed, + ) diff --git a/packages/uipath-codex-sdk/tests/__init__.py b/packages/uipath-codex-sdk/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-codex-sdk/tests/_typing.py b/packages/uipath-codex-sdk/tests/_typing.py new file mode 100644 index 00000000..e61285a0 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/_typing.py @@ -0,0 +1,55 @@ +"""Typed accessors shared by the tests. + +OpenTelemetry and ``uipath.runtime`` type several fields as optional that are +always set on the objects the tests inspect (a finished span's attributes and +timestamps, the factory's storage). These helpers assert that once, so the +assertions themselves stay plain. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from opentelemetry.sdk.trace import ReadableSpan +from uipath.runtime import UiPathRuntimeEvent, UiPathRuntimeResult + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + + +def attrs(span: ReadableSpan) -> Mapping[str, Any]: + """The span's attributes (asserted present).""" + assert span.attributes is not None, span.name + return span.attributes + + +def start_ns(span: ReadableSpan) -> int: + """The span's start time (asserted set).""" + assert span.start_time is not None, span.name + return span.start_time + + +def end_ns(span: ReadableSpan) -> int: + """The span's end time (asserted set: the span has finished).""" + assert span.end_time is not None, span.name + return span.end_time + + +def parent_span_id(span: ReadableSpan) -> int: + """The span's parent's id (asserted to have a parent).""" + assert span.parent is not None, span.name + return span.parent.span_id + + +def as_result(event: UiPathRuntimeEvent) -> UiPathRuntimeResult: + """A streamed event asserted to be the final result.""" + assert isinstance(event, UiPathRuntimeResult), event + return event + + +async def sqlite_storage(factory: UiPathCodexRuntimeFactory) -> SqliteResumableStorage: + """The factory's storage, asserted to be the SQLite resumable storage.""" + storage = await factory.get_storage() + assert isinstance(storage, SqliteResumableStorage), storage + return storage diff --git a/packages/uipath-codex-sdk/tests/contract/__init__.py b/packages/uipath-codex-sdk/tests/contract/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-codex-sdk/tests/contract/conftest.py b/packages/uipath-codex-sdk/tests/contract/conftest.py new file mode 100644 index 00000000..500d953c --- /dev/null +++ b/packages/uipath-codex-sdk/tests/contract/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for the Codex durable-tools (design A-prime) contract tests. + +These tests run the *real bundled* codex binary from ``openai-codex-cli-bin`` +against a local mock Responses API and an in-process MCP host. No network, +no UiPath tenant. Run with ``uv run pytest -m contract tests/contract -rA``. +""" + +from __future__ import annotations + +import os +import subprocess + +import pytest + +from .harness import BackgroundLoop, ContractEnv, Evidence, descendants, kill_pids + +EXPECTED_CODEX_MINOR = "0.161." + + +@pytest.fixture(scope="session", autouse=True) +def _bundled_codex_only(): + """Force the pinned bundled runtime: UIPATH_CODEX_BIN must not leak in.""" + saved = os.environ.pop("UIPATH_CODEX_BIN", None) + from codex_cli_bin import bundled_codex_path # type: ignore[import-untyped] + + path = bundled_codex_path() + version = subprocess.run( + [str(path), "--version"], capture_output=True, text=True, timeout=30 + ).stdout.strip() + print(f"\n[contract] bundled codex: {path} -> {version!r}") + assert EXPECTED_CODEX_MINOR in version, ( + f"expected bundled codex {EXPECTED_CODEX_MINOR}x, got {version!r}" + ) + yield {"path": str(path), "version": version} + if saved is not None: + os.environ["UIPATH_CODEX_BIN"] = saved + + +@pytest.fixture(scope="session") +def bg_loop(): + loop = BackgroundLoop() + yield loop + loop.close() + + +@pytest.fixture +def cenv(bg_loop, tmp_path): + before = set(descendants(os.getpid())) + env = ContractEnv(bg_loop, tmp_path) + try: + yield env + finally: + env.close() + # belt and braces: nothing spawned by this test may survive it + leftovers = [p for p in descendants(os.getpid()) if p not in before] + kill_pids(leftovers) + + +@pytest.fixture +def evidence(request): + ev = Evidence(request.node.name) + yield ev + print("\n" + ev.dump()) diff --git a/packages/uipath-codex-sdk/tests/contract/durable_mcp_host.py b/packages/uipath-codex-sdk/tests/contract/durable_mcp_host.py new file mode 100644 index 00000000..571b10a4 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/contract/durable_mcp_host.py @@ -0,0 +1,229 @@ +"""In-process "durable tools" MCP endpoint (design A-prime). + +Stateless streamable-HTTP MCP server answering with plain JSON (no SSE), +guarded by a bearer token. ``tools/call`` requests for durable tools are +recorded (including ``params._meta``) and then *held forever*: the handler +never answers, so the Codex turn stays blocked on the call until the +app-server is killed. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +from aiohttp import web + +JsonObj = dict[str, Any] + +DURABLE_TOOL = "start_job" +SERVER_NAME = "uipath_tools" + +TOOLS: list[JsonObj] = [ + { + "name": DURABLE_TOOL, + "description": "Start a long-running UiPath Orchestrator job and return its output.", + "inputSchema": { + "type": "object", + "properties": {"job": {"type": "string"}}, + "required": ["job"], + }, + } +] + + +@dataclass +class HeldCall: + jsonrpc_id: Any + params: JsonObj + received_at: float + # Snapshot taken *inside the handler* (synchronously, on arrival) by the + # optional on_call hook, e.g. what the rollout file looked like then. + arrival_snapshot: Any = None + released_at: float | None = None + disconnected: bool = False + + @property + def meta(self) -> JsonObj: + return self.params.get("_meta") or {} + + @property + def call_id(self) -> str | None: + return self.meta.get("callId") + + +@dataclass +class RpcLogEntry: + method: str | None + body: Any + auth: str | None + at: float + status: int = 200 + headers: dict[str, str] = field(default_factory=dict) + + +class DurableMcpHost: + def __init__( + self, token: str, on_call: Callable[[JsonObj], Any] | None = None + ) -> None: + self.token = token + self.on_call = on_call + self.held: list[HeldCall] = [] + self.log: list[RpcLogEntry] = [] + self.concurrent_now = 0 + self.max_concurrent = 0 + self._lock = threading.Lock() + self._runner: web.AppRunner | None = None + self._tasks: set[asyncio.Task[Any]] = set() + self._stopping = False + self.port: int | None = None + + async def start(self) -> None: + app = web.Application() + app.router.add_route("*", "/mcp", self._mcp) + # handler_cancellation: a held handler is cancelled when the client + # (codex) drops the HTTP connection -> lets tests observe disconnects. + self._runner = web.AppRunner( + app, access_log=None, shutdown_timeout=1.0, handler_cancellation=True + ) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + self.port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + + async def stop(self) -> None: + # Held handlers never finish on their own; cancel them so the + # server can shut down promptly. + self._stopping = True + for t in list(self._tasks): + t.cancel() + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.port}/mcp" + + def _record(self, entry: RpcLogEntry) -> None: + with self._lock: + self.log.append(entry) + + async def _mcp(self, request: web.Request) -> web.StreamResponse: + auth = request.headers.get("Authorization") + hdrs = {k: v for k, v in request.headers.items()} + if request.method != "POST": + # Stateless server: no standalone SSE stream, no sessions. + self._record( + RpcLogEntry( + f"HTTP {request.method}", None, auth, time.time(), 405, hdrs + ) + ) + return web.Response(status=405, headers={"Allow": "POST"}) + if auth != f"Bearer {self.token}": + self._record( + RpcLogEntry("UNAUTHORIZED", None, auth, time.time(), 401, hdrs) + ) + return web.json_response({"error": "unauthorized"}, status=401) + try: + msg = json.loads(await request.read()) + except Exception: + return web.json_response( + { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": "parse error"}, + }, + status=400, + ) + if isinstance(msg, list): # batches are not expected from Codex + self._record(RpcLogEntry("BATCH", msg, auth, time.time(), 400, hdrs)) + return web.Response(status=400) + + method = msg.get("method") + mid = msg.get("id") + self._record(RpcLogEntry(method, msg, auth, time.time(), 200, hdrs)) + + if mid is None: # notification / response from client + return web.Response(status=202) + + if method == "initialize": + pv = (msg.get("params") or {}).get("protocolVersion") or "2025-06-18" + return self._ok( + mid, + { + "protocolVersion": pv, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "uipath-durable-tools", "version": "0.0.1"}, + }, + ) + if method == "ping": + return self._ok(mid, {}) + if method == "tools/list": + return self._ok(mid, {"tools": TOOLS}) + if method == "tools/call": + return await self._hold(request, mid, msg.get("params") or {}) + return web.json_response( + { + "jsonrpc": "2.0", + "id": mid, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + ) + + def _ok(self, mid: Any, result: JsonObj) -> web.Response: + return web.json_response({"jsonrpc": "2.0", "id": mid, "result": result}) + + async def _hold( + self, request: web.Request, mid: Any, params: JsonObj + ) -> web.StreamResponse: + call = HeldCall(mid, params, time.time()) + if self.on_call is not None: + try: + call.arrival_snapshot = self.on_call(params) + except Exception as exc: # pragma: no cover - evidence only + call.arrival_snapshot = f"on_call error: {exc!r}" + task = asyncio.current_task() + if task is not None: + self._tasks.add(task) + with self._lock: + self.held.append(call) + self.concurrent_now += 1 + self.max_concurrent = max(self.max_concurrent, self.concurrent_now) + try: + # Never answer. Durable result is delivered later via thread/inject_items. + await asyncio.Event().wait() + raise AssertionError("unreachable") + except asyncio.CancelledError: + # cancelled by aiohttp because codex closed the connection + # (unless we are shutting the host down ourselves) + call.disconnected = not self._stopping + raise + finally: + call.released_at = time.time() + if task is not None: + self._tasks.discard(task) + with self._lock: + self.concurrent_now -= 1 + + # -- thread-safe helpers ------------------------------------------------ + def held_snapshot(self) -> list[HeldCall]: + with self._lock: + return list(self.held) + + def wait_for_held(self, n: int, timeout: float = 30.0) -> list[HeldCall]: + deadline = time.time() + timeout + while time.time() < deadline: + snap = self.held_snapshot() + if len(snap) >= n: + return snap + time.sleep(0.05) + return self.held_snapshot() + + def methods(self) -> list[str | None]: + with self._lock: + return [e.method for e in self.log] diff --git a/packages/uipath-codex-sdk/tests/contract/harness.py b/packages/uipath-codex-sdk/tests/contract/harness.py new file mode 100644 index 00000000..da8fe981 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/contract/harness.py @@ -0,0 +1,449 @@ +"""Glue for the durable-tools contract tests. + +* ``BackgroundLoop`` runs the aiohttp servers (mock Responses API + durable + MCP host) on an asyncio loop in a daemon thread, so tests can drive the + synchronous ``openai_codex.Codex`` client from the main thread. +* ``CodexApp`` launches the *bundled* codex app-server (never a + ``UIPATH_CODEX_BIN`` override) with a per-test ``CODEX_HOME`` and the + provider / MCP config overrides of design A-prime. +* rollout helpers locate/parse ``CODEX_HOME/sessions/**/rollout-*.jsonl``. +""" + +from __future__ import annotations + +import asyncio +import glob +import json +import os +import shutil +import signal +import threading +import time +from pathlib import Path +from typing import Any, Awaitable, Callable, TypeVar + +from openai_codex import ApprovalMode, Codex, CodexConfig, Sandbox + +from .durable_mcp_host import SERVER_NAME + +T = TypeVar("T") +JsonObj = dict[str, Any] + +MOCK_PROVIDER = "mock" +MOCK_MODEL = "mock-model" +MOCK_KEY_ENV = "MOCK_RESPONSES_KEY" +TOOLS_TOKEN_ENV = "UIPATH_TOOLS_TOKEN" + + +# -------------------------------------------------------------------------- +# background asyncio loop +# -------------------------------------------------------------------------- +class BackgroundLoop: + def __init__(self) -> None: + self.loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._run, daemon=True, name="contract-loop" + ) + self._thread.start() + + def _run(self) -> None: + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + def run(self, coro: Awaitable[T], timeout: float = 30.0) -> T: + return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout) # type: ignore[arg-type] + + def close(self) -> None: + self.loop.call_soon_threadsafe(self.loop.stop) + self._thread.join(timeout=5) + + +# -------------------------------------------------------------------------- +# process helpers +# -------------------------------------------------------------------------- +def descendants(pid: int) -> list[int]: + """All descendant pids of ``pid`` (Linux /proc walk).""" + children: dict[int, list[int]] = {} + for stat in glob.glob("/proc/[0-9]*/stat"): + try: + with open(stat) as f: + data = f.read() + # pid (comm) state ppid ... + rest = data.rsplit(")", 1)[1].split() + ppid = int(rest[1]) + children.setdefault(ppid, []).append(int(stat.split("/")[2])) + except Exception: + continue + out: list[int] = [] + stack = [pid] + while stack: + p = stack.pop() + for c in children.get(p, []): + out.append(c) + stack.append(c) + return out + + +def pid_alive(pid: int) -> bool: + try: + with open(f"/proc/{pid}/stat") as f: + state = f.read().rsplit(")", 1)[1].split()[0] + return state != "Z" + except FileNotFoundError: + return False + + +def kill_pids(pids: list[int]) -> None: + for p in pids: + try: + os.kill(p, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError: + pass + + +# -------------------------------------------------------------------------- +# Codex app-server wrapper +# -------------------------------------------------------------------------- +def build_overrides( + mock_base_url: str, + mcp_url: str | None, + *, + parallel: bool = True, + extra: tuple[str, ...] = (), +) -> tuple[str, ...]: + ov = [ + f'model_providers.{MOCK_PROVIDER}.name="Mock"', + f'model_providers.{MOCK_PROVIDER}.base_url="{mock_base_url}"', + f'model_providers.{MOCK_PROVIDER}.wire_api="responses"', + f'model_providers.{MOCK_PROVIDER}.env_key="{MOCK_KEY_ENV}"', + f"model_providers.{MOCK_PROVIDER}.request_max_retries=0", + f"model_providers.{MOCK_PROVIDER}.stream_max_retries=0", + # keep the run hermetic: no remote plugin / chatgpt backend traffic + "features.plugins=false", + 'chatgpt_base_url="http://127.0.0.1:9/backend-api"', + ] + if mcp_url is not None: + s = f"mcp_servers.{SERVER_NAME}" + ov += [ + f'{s}.url="{mcp_url}"', + f'{s}.bearer_token_env_var="{TOOLS_TOKEN_ENV}"', + f'{s}.default_tools_approval_mode="approve"', + f"{s}.tool_timeout_sec=3600", + f"{s}.startup_timeout_sec=30", + f"{s}.supports_parallel_tool_calls={'true' if parallel else 'false'}", + ] + ov += list(extra) + return tuple(ov) + + +class CodexApp: + """One codex app-server process (bundled binary) bound to a CODEX_HOME.""" + + def __init__( + self, codex_home: Path, cwd: Path, overrides: tuple[str, ...], token: str + ) -> None: + self.codex_home = codex_home + self.cwd = cwd + env = { + "CODEX_HOME": str(codex_home), + MOCK_KEY_ENV: "mock-secret", + TOOLS_TOKEN_ENV: token, + "RUST_LOG": os.environ.get("CONTRACT_RUST_LOG", "warn"), + # Hermetic: any non-loopback HTTP(S) goes to a dead proxy. + "HTTPS_PROXY": "http://127.0.0.1:9", + "HTTP_PROXY": "http://127.0.0.1:9", + "ALL_PROXY": "http://127.0.0.1:9", + "NO_PROXY": "127.0.0.1,localhost", + "https_proxy": "http://127.0.0.1:9", + "http_proxy": "http://127.0.0.1:9", + "all_proxy": "http://127.0.0.1:9", + "no_proxy": "127.0.0.1,localhost", + } + cfg = CodexConfig(config_overrides=overrides, cwd=str(cwd), env=env) + assert cfg.codex_bin is None # always the bundled runtime + self.codex = Codex(cfg) + self.metadata = self.codex.metadata + proc = self.codex._client._proc + assert proc is not None + self.pid = proc.pid + self._known_children: list[int] = [] + self.killed = False + + # thread helpers ------------------------------------------------------ + def thread_start(self, cwd: Path | None = None): + return self.codex.thread_start( + model=MOCK_MODEL, + model_provider=MOCK_PROVIDER, + sandbox=Sandbox.read_only, + approval_mode=ApprovalMode.deny_all, + cwd=str(cwd or self.cwd), + ) + + def thread_resume(self, thread_id: str, cwd: Path | None = None): + return self.codex.thread_resume( + thread_id, + model=MOCK_MODEL, + model_provider=MOCK_PROVIDER, + sandbox=Sandbox.read_only, + approval_mode=ApprovalMode.deny_all, + cwd=str(cwd) if cwd else None, + ) + + def raw(self, method: str, params: JsonObj) -> Any: + return self.codex._client._request_raw(method, params) + + def stderr_tail(self, n: int = 60) -> str: + return self.codex._client._stderr_tail(n) + + # termination --------------------------------------------------------- + def sigkill(self) -> list[int]: + """Design A-prime suspend: SIGKILL the app-server process itself.""" + kids = descendants(self.pid) + self._known_children = kids + proc = self.codex._client._proc + assert proc is not None + proc.kill() # == codex._client._proc.kill(); SIGKILL, no graceful shutdown + proc.wait(timeout=10) + self.killed = True + return kids + + def cleanup(self) -> None: + """Always-safe teardown: kill process tree, close client threads.""" + try: + kids = set(self._known_children) | set(descendants(self.pid)) + except Exception: + kids = set(self._known_children) + proc = self.codex._client._proc + if proc is not None and proc.poll() is None: + try: + proc.kill() + proc.wait(timeout=10) + except Exception: + pass + kill_pids(sorted(kids)) + try: + self.codex.close() + except Exception: + pass + + +# -------------------------------------------------------------------------- +# rollout helpers +# -------------------------------------------------------------------------- +def find_rollouts(codex_home: Path) -> list[Path]: + return sorted( + Path(p) + for p in glob.glob( + str(codex_home / "sessions" / "**" / "rollout-*.jsonl"), recursive=True + ) + ) + + +def rollout_for_thread(codex_home: Path, thread_id: str) -> Path | None: + for p in find_rollouts(codex_home): + if thread_id in p.name: + return p + return None + + +def read_rollout(path: Path) -> list[JsonObj]: + out: list[JsonObj] = [] + try: + text = path.read_text() + except FileNotFoundError: + return out + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + out.append({"_unparsable": line[:200]}) + return out + + +def response_items(lines: list[JsonObj]) -> list[JsonObj]: + return [ + ln.get("payload") or {} for ln in lines if ln.get("type") == "response_item" + ] + + +def rollout_function_calls( + lines: list[JsonObj], call_id: str | None = None +) -> list[JsonObj]: + return [ + p + for p in response_items(lines) + if p.get("type") == "function_call" + and (call_id is None or p.get("call_id") == call_id) + ] + + +def rollout_function_outputs( + lines: list[JsonObj], call_id: str | None = None +) -> list[JsonObj]: + return [ + p + for p in response_items(lines) + if p.get("type") + in ("function_call_output", "custom_tool_call_output", "mcp_tool_call_output") + and (call_id is None or p.get("call_id") == call_id) + ] + + +def turn_aborted_lines(lines: list[JsonObj]) -> list[JsonObj]: + return [ln for ln in lines if "turn_aborted" in json.dumps(ln)] + + +def compact_line(ln: JsonObj, limit: int = 300) -> str: + s = json.dumps({"type": ln.get("type"), "payload": ln.get("payload")}) + return s if len(s) <= limit else s[:limit] + "..." + + +def restore_rollout(src_home: Path, rollout: Path, dst_home: Path) -> Path: + """Copy only the rollout jsonl into a fresh CODEX_HOME at the same relative path.""" + rel = rollout.relative_to(src_home) + dst = dst_home / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(rollout, dst) + return dst + + +def wait_until( + pred: Callable[[], bool], timeout: float = 20.0, interval: float = 0.05 +) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if pred(): + return True + time.sleep(interval) + return pred() + + +def dir_listing(root: Path) -> list[str]: + return sorted(str(p.relative_to(root)) for p in root.rglob("*")) + + +# -------------------------------------------------------------------------- +# per-test environment + scripted durable scenario +# -------------------------------------------------------------------------- +TRIGGER = "PLEASE_CALL_DURABLE" + + +class DurableScript: + """Mock-model policy. + + * a user turn containing TRIGGER, whose input has not yet seen our calls + -> one response with one function_call per id in ``call_ids`` + (using the exact name/namespace Codex advertised for the MCP tool); + * anything else -> a final assistant message. + """ + + def __init__(self, call_ids: list[str], tool_needle: str = "start_job") -> None: + self.call_ids = call_ids + self.tool_needle = tool_needle + self.advertised: tuple[str | None, str | None] | None = None + + def __call__(self, idx: int, body: JsonObj) -> list[JsonObj] | None: + from .mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + last_user_text, + ) + + if TRIGGER not in last_user_text(body): + return None + seen = { + it.get("call_id") + for it in input_items(body) + if it.get("type") == "function_call" + } + if seen & set(self.call_ids): + return None + name, ns = find_tool(advertised_tools(body), self.tool_needle) + self.advertised = (name, ns) + if not name: + return None # tool not advertised -> final message; tests will fail with evidence + return [ + function_call_item(cid, name, {"job": f"job-{i}"}, ns) + for i, cid in enumerate(self.call_ids) + ] + + +class ContractEnv: + """Owns servers, codex processes and temp dirs for one test; always cleans up.""" + + def __init__(self, loop: BackgroundLoop, root: Path) -> None: + from .mock_responses import MockResponsesServer + + self.loop = loop + self.root = root + self.script = DurableScript([]) + self.mock = MockResponsesServer(self.script) + loop.run(self.mock.start()) + self.hosts: list[Any] = [] + self.apps: list[CodexApp] = [] + + def dir(self, name: str) -> Path: + p = self.root / name + p.mkdir(parents=True, exist_ok=True) + return p + + def new_host(self, token: str, on_call: Callable[[JsonObj], Any] | None = None): + from .durable_mcp_host import DurableMcpHost + + host = DurableMcpHost(token, on_call=on_call) + self.loop.run(host.start()) + self.hosts.append(host) + return host + + def new_app( + self, home: Path, cwd: Path, host: Any, *, parallel: bool = True + ) -> CodexApp: + app = CodexApp( + home, + cwd, + build_overrides(self.mock.base_url, host.url, parallel=parallel), + host.token, + ) + self.apps.append(app) + return app + + def close(self) -> None: + for app in self.apps: + app.cleanup() + for host in self.hosts: + try: + self.loop.run(host.stop(), timeout=10) + except Exception: + pass + try: + self.loop.run(self.mock.stop(), timeout=10) + except Exception: + pass + + +class Evidence: + def __init__(self, name: str) -> None: + self.name = name + self.entries: list[tuple[str, str]] = [] + + def add(self, label: str, value: Any) -> None: + if not isinstance(value, str): + try: + value = json.dumps(value, indent=1, default=str) + except Exception: + value = repr(value) + self.entries.append((label, value)) + + def dump(self, limit: int = 20000) -> str: + out = [f"===== EVIDENCE [{self.name}] ====="] + for label, value in self.entries: + out.append(f"--- {label}:\n{value}") + s = "\n".join(out) + return s if len(s) <= limit else s[:limit] + "\n..." diff --git a/packages/uipath-codex-sdk/tests/contract/mock_responses.py b/packages/uipath-codex-sdk/tests/contract/mock_responses.py new file mode 100644 index 00000000..883b3589 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/contract/mock_responses.py @@ -0,0 +1,260 @@ +"""Local mock of the OpenAI Responses API (SSE) used as Codex's model provider. + +Codex is registered against it via ``model_providers.mock.*`` config overrides. +Every request body is recorded so tests can assert on exactly what the model +would have seen. Responses are scripted by a callable supplied by the test. +""" + +from __future__ import annotations + +import json +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +from aiohttp import web + +JsonObj = dict[str, Any] +# script(request_index, body) -> list of Responses output items, or None for a +# default final assistant message. +Script = Callable[[int, JsonObj], "list[JsonObj] | None"] + + +def function_call_item( + call_id: str, + name: str, + arguments: JsonObj | None = None, + namespace: str | None = None, +) -> JsonObj: + item: JsonObj = { + "type": "function_call", + "id": f"fc_{call_id}", + "call_id": call_id, + "name": name, + "arguments": json.dumps(arguments or {}), + "status": "completed", + } + if namespace: + item["namespace"] = namespace + return item + + +def message_item(text: str, idx: int = 0) -> JsonObj: + return { + "type": "message", + "id": f"msg_{idx}_{int(time.time() * 1000)}", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + + +def advertised_tools(body: JsonObj) -> list[JsonObj]: + """Tools Codex offered the model in a request. + + Older models get them in the top-level ``tools`` field; newer catalog + entries (e.g. gpt-5.6) carry them inside the input, as ``tools`` on a + developer message. + """ + tools = list(body.get("tools") or []) + for item in body.get("input") or []: + if isinstance(item, dict) and isinstance(item.get("tools"), list): + tools.extend(item["tools"]) + return tools + + +def find_tool(tools: list[JsonObj], needle: str) -> tuple[str | None, str | None]: + """Find an advertised tool whose name contains ``needle``. + + Returns (name, namespace). Codex may advertise MCP tools flat + (``mcp__server__tool``) or grouped under a ``namespace`` entry. + """ + for t in tools or []: + name = t.get("name") + if t.get("type") == "namespace" or isinstance(t.get("tools"), list): + for sub in t.get("tools") or []: + sname = sub.get("name") or "" + if needle in sname: + return sname, name + if name and needle in name: + return name, None + return None, None + + +def input_items(body: JsonObj) -> list[JsonObj]: + inp = body.get("input") + return inp if isinstance(inp, list) else [] + + +def last_user_text(body: JsonObj) -> str: + for item in reversed(input_items(body)): + if item.get("type") == "message" and item.get("role") == "user": + parts = item.get("content") or [] + if isinstance(parts, str): + return parts + return " ".join(p.get("text", "") for p in parts if isinstance(p, dict)) + return "" + + +@dataclass +class RecordedRequest: + index: int + path: str + method: str + body: JsonObj + received_at: float + headers: dict[str, str] = field(default_factory=dict) + + +class MockResponsesServer: + def __init__(self, script: Script | None = None) -> None: + self.script: Script | None = script + self.requests: list[RecordedRequest] = [] + self.other_requests: list[tuple[str, str]] = [] + self._lock = threading.Lock() + self._runner: web.AppRunner | None = None + self.port: int | None = None + + # -- lifecycle (called on the background loop) ------------------------- + async def start(self) -> None: + app = web.Application(client_max_size=64 * 1024 * 1024) + app.router.add_post("/v1/responses", self._responses) + app.router.add_route("*", "/{tail:.*}", self._other) + self._runner = web.AppRunner(app, access_log=None, shutdown_timeout=1.0) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + self.port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.port}/v1" + + # -- handlers ---------------------------------------------------------- + async def _other(self, request: web.Request) -> web.Response: + with self._lock: + self.other_requests.append((request.method, request.path)) + return web.json_response({"error": "not mocked"}, status=404) + + async def _responses(self, request: web.Request) -> web.StreamResponse: + raw = await request.read() + body = json.loads(raw) if raw else {} + with self._lock: + idx = len(self.requests) + self.requests.append( + RecordedRequest( + idx, + request.path, + request.method, + body, + time.time(), + {k: v for k, v in request.headers.items()}, + ) + ) + items: list[JsonObj] | None = None + if self.script is not None: + try: + items = self.script(idx, body) + except Exception as exc: # surface script bugs to the model text + items = [message_item(f"MOCK_SCRIPT_ERROR {exc!r}", idx)] + if not items: + items = [message_item(f"final answer {idx}", idx)] + + rid = f"resp_{idx}" + events: list[JsonObj] = [ + {"type": "response.created", "response": {"id": rid}}, + ] + for i, item in enumerate(items): + events.append( + {"type": "response.output_item.added", "output_index": i, "item": item} + ) + events.append( + {"type": "response.output_item.done", "output_index": i, "item": item} + ) + events.append( + { + "type": "response.completed", + "response": { + "id": rid, + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + }, + } + ) + data = b"".join( + f"event: {e['type']}\ndata: {json.dumps(e)}\n\n".encode() for e in events + ) + resp = web.StreamResponse( + status=200, headers={"content-type": "text/event-stream"} + ) + resp.content_length = len(data) + await resp.prepare(request) + await resp.write(data) + await resp.write_eof() + return resp + + # -- helpers for tests (thread-safe) ----------------------------------- + def snapshot(self) -> list[RecordedRequest]: + with self._lock: + return list(self.requests) + + def wait_for_requests(self, n: int, timeout: float = 30.0) -> list[RecordedRequest]: + deadline = time.time() + timeout + while time.time() < deadline: + snap = self.snapshot() + if len(snap) >= n: + return snap + time.sleep(0.05) + return self.snapshot() + + +def summarize_input(body: JsonObj, max_text: int = 160) -> list[JsonObj]: + """Compact view of request input items for evidence printing.""" + out = [] + for it in input_items(body): + d: JsonObj = { + k: it[k] + for k in ("type", "role", "call_id", "name", "namespace") + if k in it + } + if "output" in it: + d["output"] = ( + it["output"] + if len(json.dumps(it["output"])) < 400 + else str(it["output"])[:400] + ) + if "arguments" in it: + d["arguments"] = it["arguments"] + if "content" in it: + c = it["content"] + txt = ( + c + if isinstance(c, str) + else " | ".join(p.get("text", "") for p in c if isinstance(p, dict)) + ) + d["text"] = txt[:max_text] + ("..." if len(txt) > max_text else "") + out.append(d) + return out + + +__all__ = [ + "MockResponsesServer", + "RecordedRequest", + "function_call_item", + "message_item", + "find_tool", + "input_items", + "last_user_text", + "summarize_input", +] diff --git a/packages/uipath-codex-sdk/tests/contract/test_durable_contract.py b/packages/uipath-codex-sdk/tests/contract/test_durable_contract.py new file mode 100644 index 00000000..071cfa82 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/contract/test_durable_contract.py @@ -0,0 +1,525 @@ +"""Contract tests for durable MCP tools via SIGKILL-suspend + inject-resume (design A-prime). + +Each test drives the real bundled codex 0.161 app-server against: + * ``MockResponsesServer`` - scripted OpenAI Responses SSE "model"; + * ``DurableMcpHost`` - in-process stateless streamable-HTTP MCP server + that records ``tools/call`` and never answers. + +Claims (see module docstrings on each test): C1..C7. Assertions are strict; +evidence (request input items, rollout lines) is printed for every test +(``-rA`` shows it for passes too). +""" + +from __future__ import annotations + +import json +import re +import shutil +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from .durable_mcp_host import DurableMcpHost, HeldCall +from .harness import ( + TRIGGER, + CodexApp, + ContractEnv, + Evidence, + compact_line, + dir_listing, + find_rollouts, + pid_alive, + read_rollout, + response_items, + restore_rollout, + rollout_for_thread, + rollout_function_calls, + rollout_function_outputs, + turn_aborted_lines, + wait_until, +) +from .mock_responses import advertised_tools, input_items, summarize_input + +pytestmark = pytest.mark.contract + +OUR_OUTPUT = 'UIPATH_JOB_OUTPUT {"status": "Successful", "result": 42}' +ABORT_RE = re.compile(r"aborted", re.IGNORECASE) + + +def new_call_id() -> str: + return f"call_{uuid.uuid4().hex[:24]}" + + +@dataclass +class Suspended: + app: CodexApp + host: DurableMcpHost + home: Path + cwd: Path + thread_id: str + turn: Any + held: list[HeldCall] + call_ids: list[str] + + +def wait_quiet( + cenv: ContractEnv, + home: Path, + thread_id: str, + quiet: float = 1.0, + max_wait: float = 15.0, +) -> None: + """Wait until neither model requests nor the rollout file change for ``quiet`` s.""" + + def state(): + ro = rollout_for_thread(home, thread_id) + return ( + len(cenv.mock.snapshot()), + ro.stat().st_size if ro and ro.exists() else -1, + ) + + deadline = time.time() + max_wait + last, since = state(), time.time() + while time.time() < deadline: + time.sleep(0.1) + cur = state() + if cur != last: + last, since = cur, time.time() + elif time.time() - since >= quiet: + return + + +def start_and_hold( + cenv: ContractEnv, + ev: Evidence, + call_ids: list[str], + *, + parallel: bool = True, + on_call=None, + home_name: str = "home", + cwd_name: str = "cwdA", + expect_held: int | None = None, + hold_timeout: float = 30.0, +) -> Suspended: + cenv.script.call_ids = call_ids + home, cwd = cenv.dir(home_name), cenv.dir(cwd_name) + host = cenv.new_host(f"tok-{uuid.uuid4().hex[:8]}", on_call=on_call) + app = cenv.new_app(home, cwd, host, parallel=parallel) + ev.add("initialize metadata", app.metadata.model_dump(mode="json")) + thread = app.thread_start() + turn = thread.turn(f"{TRIGGER}: start the durable job(s) now") + n = expect_held if expect_held is not None else len(call_ids) + held = host.wait_for_held(n, hold_timeout) + ev.add("advertised durable tool (name, namespace)", cenv.script.advertised) + ev.add("MCP host JSON-RPC methods seen", host.methods()) + ev.add("MCP held tools/call params", [c.params for c in held]) + return Suspended(app, host, home, cwd, thread.id, turn, held, call_ids) + + +def rollout_evidence(ev: Evidence, label: str, lines: list[dict]) -> None: + ev.add(label, "\n".join(compact_line(ln, 420) for ln in lines)) + + +def first_request_with_trigger(cenv: ContractEnv): + for r in cenv.mock.snapshot(): + if any(TRIGGER in json.dumps(it) for it in input_items(r.body)): + return r + return None + + +# --------------------------------------------------------------------------- +# C1 +# --------------------------------------------------------------------------- +def test_c1_tools_call_carries_meta_call_id(cenv: ContractEnv, evidence: Evidence): + """C1: Codex sends params._meta.callId on MCP tools/call, equal to the + Responses function_call call_id.""" + cid = new_call_id() + s = start_and_hold(cenv, evidence, [cid]) + assert len(s.held) == 1, ( + f"durable tools/call never reached the host\n{evidence.dump()}" + ) + call = s.held[0] + evidence.add("held._meta", call.meta) + assert call.params.get("name") == "start_job", evidence.dump() + assert call.params.get("arguments") == {"job": "job-0"}, evidence.dump() + assert "_meta" in call.params, f"no _meta on tools/call\n{evidence.dump()}" + assert call.meta.get("callId") == cid, ( + f"_meta.callId={call.meta.get('callId')!r} != Responses call_id {cid!r}\n{evidence.dump()}" + ) + # and it is the same id Codex persisted for the function_call + wait_quiet(cenv, s.home, s.thread_id) + rollout = rollout_for_thread(s.home, s.thread_id) + assert rollout is not None, evidence.dump() + lines = read_rollout(rollout) + assert [fc.get("call_id") for fc in rollout_function_calls(lines)] == [cid], ( + evidence.dump() + ) + + +# --------------------------------------------------------------------------- +# C2 +# --------------------------------------------------------------------------- +def test_c2_parallel_durable_calls_held_concurrently( + cenv: ContractEnv, evidence: Evidence +): + """C2: with supports_parallel_tool_calls=true, two durable calls in one model + response are both dispatched to the host and held at the same time.""" + ids = [new_call_id(), new_call_id()] + s = start_and_hold(cenv, evidence, ids, parallel=True, hold_timeout=20.0) + req = first_request_with_trigger(cenv) + evidence.add( + "model request parallel_tool_calls", + req.body.get("parallel_tool_calls") if req else None, + ) + evidence.add("host.max_concurrent", s.host.max_concurrent) + evidence.add( + "held call ids / received_at / released_at", + [(c.call_id, c.received_at, c.released_at) for c in s.held], + ) + assert sorted(str(c.call_id) for c in s.held) == sorted(ids), ( + f"expected both durable calls dispatched, got {[c.call_id for c in s.held]}\n{evidence.dump()}" + ) + assert all(c.released_at is None for c in s.held), evidence.dump() + assert s.host.max_concurrent == 2 and s.host.concurrent_now == 2, evidence.dump() + + +def test_c2_control_serial_without_parallel_flag(cenv: ContractEnv, evidence: Evidence): + """C2 control: with supports_parallel_tool_calls=false only the first durable + call is dispatched while it is held (shows the flag is what enables C2).""" + ids = [new_call_id(), new_call_id()] + s = start_and_hold( + cenv, evidence, ids, parallel=False, expect_held=2, hold_timeout=8.0 + ) + evidence.add("held call ids", [c.call_id for c in s.held]) + assert len(s.held) == 1, ( + f"expected serialized dispatch (1 held) without the flag, got {len(s.held)}\n{evidence.dump()}" + ) + + +# --------------------------------------------------------------------------- +# C3 +# --------------------------------------------------------------------------- +def test_c3_sigkill_leaves_dangling_function_call_in_rollout( + cenv: ContractEnv, evidence: Evidence +): + """C3: after SIGKILL while a call is held, the rollout contains the + function_call and NO output for that call_id, and no turn_aborted marker.""" + cid = new_call_id() + s = start_and_hold(cenv, evidence, [cid]) + assert len(s.held) == 1, evidence.dump() + wait_quiet(cenv, s.home, s.thread_id) + kids = s.app.sigkill() + evidence.add( + "app-server pid / alive after kill / children", + (s.app.pid, pid_alive(s.app.pid), kids), + ) + assert not pid_alive(s.app.pid) + wait_until(lambda: s.held[0].disconnected, 5) + evidence.add( + "held HTTP request dropped by codex after SIGKILL (disconnected, released_at)", + (s.held[0].disconnected, s.held[0].released_at), + ) + ro = rollout_for_thread(s.home, s.thread_id) + assert ro is not None, f"no rollout for thread\n{dir_listing(s.home)}" + lines = read_rollout(ro) + rollout_evidence(evidence, f"rollout {ro.relative_to(s.home)}", lines) + fcs = rollout_function_calls(lines, cid) + outs = rollout_function_outputs(lines, cid) + aborted = turn_aborted_lines(lines) + any_abort_text = [ + compact_line(ln) for ln in lines if ABORT_RE.search(json.dumps(ln)) + ] + assert len(fcs) == 1, f"function_call for {cid} not in rollout\n{evidence.dump()}" + assert outs == [], f"unexpected output(s) for {cid}: {outs}\n{evidence.dump()}" + assert aborted == [], f"turn_aborted marker present\n{evidence.dump()}" + assert any_abort_text == [], f"'aborted' text in rollout\n{evidence.dump()}" + # the dangling function_call is the last model-visible item + assert response_items(lines)[-1].get("call_id") == cid, evidence.dump() + + +# --------------------------------------------------------------------------- +# C4 + C5 (shared suspend -> restore -> resume) +# --------------------------------------------------------------------------- +def suspend_and_restore(cenv: ContractEnv, ev: Evidence, cid: str): + s = start_and_hold(cenv, ev, [cid]) + assert len(s.held) == 1, ev.dump() + wait_quiet(cenv, s.home, s.thread_id) + s.app.sigkill() + ro = rollout_for_thread(s.home, s.thread_id) + assert ro is not None, ev.dump() + # fresh CODEX_HOME containing ONLY the restored rollout; original cwd is gone + home2, cwd2 = cenv.dir("home2"), cenv.dir("cwdB_other_robot") + restored = restore_rollout(s.home, ro, home2) + shutil.rmtree(s.cwd) + ev.add("fresh CODEX_HOME listing before launch", dir_listing(home2)) + assert [p for p in home2.rglob("*") if p.is_file()] == [restored], ev.dump() + host2 = cenv.new_host(f"tok2-{uuid.uuid4().hex[:8]}") + app2 = cenv.new_app(home2, cwd2, host2) + return s, app2, host2, home2, cwd2 + + +def test_c4_resume_in_fresh_home_from_different_cwd( + cenv: ContractEnv, evidence: Evidence +): + """C4: a new app-server with a fresh CODEX_HOME holding only the restored + rollout, launched from a different cwd (original cwd deleted), can + thread_resume the thread.""" + cid = new_call_id() + s, app2, host2, home2, cwd2 = suspend_and_restore(cenv, evidence, cid) + evidence.add("new process cwd", str(cwd2)) + try: + th2 = app2.thread_resume(s.thread_id, cwd=cwd2) + except Exception as exc: + evidence.add("thread_resume error", repr(exc)) + evidence.add("stderr", app2.stderr_tail()) + pytest.fail(f"thread_resume failed: {exc!r}\n{evidence.dump()}") + assert th2.id == s.thread_id, evidence.dump() + read = th2.read(include_turns=True).model_dump(mode="json") + turns = read["thread"].get("turns") or [] + evidence.add( + "resumed thread turns (status, item types)", + [(t.get("status"), [i.get("type") for i in t.get("items", [])]) for t in turns], + ) + evidence.add( + "resumed thread cwd/path", + (read["thread"].get("cwd"), read["thread"].get("path")), + ) + items = [i for t in turns for i in t.get("items", [])] + assert any(i.get("type") == "mcpToolCall" for i in items) or any( + TRIGGER in json.dumps(i) for i in items + ), f"resumed history missing prior turn\n{evidence.dump()}" + + +def test_c5_inject_output_then_continue_yields_clean_pair( + cenv: ContractEnv, evidence: Evidence +): + """C5: thread/inject_items(function_call_output same call_id) + turn/start + -> next model request has function_call + our output paired by call_id, + no 'aborted' output and no .""" + cid = new_call_id() + s, app2, host2, home2, cwd2 = suspend_and_restore(cenv, evidence, cid) + th2 = app2.thread_resume(s.thread_id, cwd=cwd2) + n_before = len(cenv.mock.snapshot()) + inj = app2.raw( + "thread/inject_items", + { + "threadId": s.thread_id, + "items": [ + {"type": "function_call_output", "call_id": cid, "output": OUR_OUTPUT} + ], + }, + ) + evidence.add("thread/inject_items result", inj) + evidence.add( + "model requests made by inject alone", len(cenv.mock.snapshot()) - n_before + ) + handle = th2.turn("continue") + reqs = cenv.mock.wait_for_requests(n_before + 1, 30) + assert len(reqs) > n_before, ( + f"no model request after turn/start\n{app2.stderr_tail()}\n{evidence.dump()}" + ) + body = reqs[n_before].body + items = input_items(body) + evidence.add("post-resume model request input (summary)", summarize_input(body)) + from .mock_responses import find_tool + + evidence.add( + "post-resume request re-advertises durable tool (name, namespace)", + find_tool(advertised_tools(body), "start_job"), + ) + result = handle.run() + evidence.add( + "resumed turn status / final", (str(result.status), result.final_response) + ) + evidence.add("host2 methods (should contain no tools/call)", host2.methods()) + + calls = [ + (i, it) + for i, it in enumerate(items) + if it.get("type") == "function_call" and it.get("call_id") == cid + ] + outs = [ + (i, it) + for i, it in enumerate(items) + if it.get("type", "").endswith("_output") and it.get("call_id") == cid + ] + assert len(calls) == 1, ( + f"expected exactly one function_call {cid}\n{evidence.dump()}" + ) + assert len(outs) == 1, ( + f"expected exactly one output for {cid}, got {len(outs)}\n{evidence.dump()}" + ) + (ci, call), (oi, out) = calls[0], outs[0] + assert out["type"] == "function_call_output", evidence.dump() + assert out.get("output") == OUR_OUTPUT, evidence.dump() + assert oi > ci, f"output precedes call\n{evidence.dump()}" + assert ( + call.get("name") == "start_job" and call.get("namespace") == "mcp__uipath_tools" + ), evidence.dump() + # nothing between the call and its output besides other tool items + between = [it.get("type") for it in items[ci + 1 : oi]] + assert between == [], f"items between call and output: {between}\n{evidence.dump()}" + blob = json.dumps(items) + assert "" not in blob and "turn_aborted" not in blob, evidence.dump() + assert not any( + ABORT_RE.search(json.dumps(it.get("output", ""))) + for it in items + if it.get("type", "").endswith("_output") + ), evidence.dump() + assert result.final_response, evidence.dump() + assert "tools/call" not in host2.methods(), ( + evidence.dump() + ) # durable call not re-dispatched + + +# --------------------------------------------------------------------------- +# C6 +# --------------------------------------------------------------------------- +RE_ROLLOUT = re.compile( + r"^sessions/(\d{4})/(\d{2})/(\d{2})/rollout-(\d{4})-(\d{2})-(\d{2})T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})\.jsonl$" +) + + +def test_c6_rollout_path_pattern(cenv: ContractEnv, evidence: Evidence): + """C6a: rollout lives at CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl.""" + cid = new_call_id() + s = start_and_hold(cenv, evidence, [cid]) + wait_quiet(cenv, s.home, s.thread_id) + rels = [str(p.relative_to(s.home)) for p in find_rollouts(s.home)] + evidence.add("rollout files", rels) + evidence.add("CODEX_HOME top level", sorted(p.name for p in s.home.iterdir())) + assert len(rels) == 1, evidence.dump() + m = RE_ROLLOUT.match(rels[0]) + assert m, f"unexpected rollout path {rels[0]}\n{evidence.dump()}" + assert m.group(7) == s.thread_id, evidence.dump() + assert m.group(1, 2, 3) == m.group(4, 5, 6), evidence.dump() + + +@pytest.mark.parametrize("rep", range(3)) +def test_c6_function_call_on_disk_when_tools_call_arrives( + cenv: ContractEnv, evidence: Evidence, rep: int +): + """C6b: when the host's tools/call handler runs, the function_call for that + call_id is already persisted in the rollout (snapshot taken inside the handler).""" + home_holder: dict[str, Path] = {} + + def on_call(params: dict) -> dict: + home = home_holder["home"] + cid = (params.get("_meta") or {}).get("callId") + snap: dict[str, Any] = {"t": time.time(), "callId": cid, "files": []} + for ro in find_rollouts(home): + lines = read_rollout(ro) + snap["files"].append( + { + "path": str(ro.relative_to(home)), + "n_lines": len(lines), + "fc_on_disk": bool(rollout_function_calls(lines, cid)), + "last_line": compact_line(lines[-1], 300) if lines else None, + } + ) + return snap + + home_holder["home"] = cenv.dir("home") + cid = new_call_id() + s = start_and_hold(cenv, evidence, [cid], on_call=on_call) + assert len(s.held) == 1, evidence.dump() + snap = s.held[0].arrival_snapshot + evidence.add("rollout snapshot taken inside tools/call handler", snap) + # when (relative to arrival) does it appear, if not already? + ro_ok = wait_until( + lambda: bool( + rollout_function_calls( + read_rollout( + rollout_for_thread(s.home, s.thread_id) or Path("/nonexistent") + ), + cid, + ) + ), + 10, + ) + evidence.add("function_call on disk within 10s after arrival", ro_ok) + assert ro_ok, evidence.dump() + assert snap and snap["files"], ( + f"no rollout file existed at tools/call arrival\n{evidence.dump()}" + ) + assert any(f["fc_on_disk"] for f in snap["files"]), ( + f"function_call NOT yet flushed when tools/call arrived\n{evidence.dump()}" + ) + + +# --------------------------------------------------------------------------- +# C7 +# --------------------------------------------------------------------------- +def test_c7_turn_interrupt_pollutes_history(cenv: ContractEnv, evidence: Evidence): + """C7: using turn/interrupt instead of SIGKILL records an 'aborted' output + and/or marker that the model sees on the next request + (i.e. SIGKILL is necessary for a clean resume).""" + cid = new_call_id() + s = start_and_hold(cenv, evidence, [cid]) + assert len(s.held) == 1, evidence.dump() + wait_quiet(cenv, s.home, s.thread_id) + evidence.add("turn/interrupt result", s.turn.interrupt().model_dump(mode="json")) + done = s.turn.run() + evidence.add("interrupted turn status", str(done.status)) + wait_quiet(cenv, s.home, s.thread_id) + wait_until(lambda: s.held[0].released_at is not None, 5) + evidence.add("host methods after interrupt", s.host.methods()) + evidence.add( + "held call released/disconnected", + (s.held[0].released_at, s.held[0].disconnected), + ) + rollout = rollout_for_thread(s.home, s.thread_id) + assert rollout is not None, evidence.dump() + lines = read_rollout(rollout) + rollout_evidence(evidence, "rollout after interrupt", lines[-8:]) + rollout_outs = rollout_function_outputs(lines, cid) + rollout_aborted = turn_aborted_lines(lines) + + # what the model sees next: inject our output (as on resume) + continue + n_before = len(cenv.mock.snapshot()) + s.app.raw( + "thread/inject_items", + { + "threadId": s.thread_id, + "items": [ + {"type": "function_call_output", "call_id": cid, "output": OUR_OUTPUT} + ], + }, + ) + t2 = s.app.raw( + "turn/start", + {"threadId": s.thread_id, "input": [{"type": "text", "text": "continue"}]}, + ) + evidence.add( + "turn/start", t2.get("turn", {}).get("id") if isinstance(t2, dict) else t2 + ) + reqs = cenv.mock.wait_for_requests(n_before + 1, 30) + assert len(reqs) > n_before, evidence.dump() + items = input_items(reqs[n_before].body) + evidence.add( + "next model request input (summary)", summarize_input(reqs[n_before].body) + ) + outs_for_call = [ + it + for it in items + if it.get("type", "").endswith("_output") and it.get("call_id") == cid + ] + aborted_outputs = [ + it for it in outs_for_call if ABORT_RE.search(json.dumps(it.get("output", ""))) + ] + marker = "turn_aborted" in json.dumps(items) + evidence.add( + "rollout outputs for call / turn_aborted lines", + (rollout_outs, [compact_line(x) for x in rollout_aborted]), + ) + evidence.add( + "request: outputs for call / aborted outputs / marker", + (outs_for_call, aborted_outputs, marker), + ) + assert aborted_outputs or marker, ( + f"turn/interrupt left a clean history -> SIGKILL would NOT be necessary\n{evidence.dump()}" + ) diff --git a/packages/uipath-codex-sdk/tests/durable/__init__.py b/packages/uipath-codex-sdk/tests/durable/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-codex-sdk/tests/durable/conftest.py b/packages/uipath-codex-sdk/tests/durable/conftest.py new file mode 100644 index 00000000..cfeed8f7 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/conftest.py @@ -0,0 +1,142 @@ +"""Fixtures for the offline durable-tool end-to-end tests. + +Real bundled codex 0.161 binary + real gateway shim / tool host / runtime / +factory / ``UiPathResumableRuntime`` / ``UiPathResumeTriggerHandler``; only the +UiPath tenant is faked (Orchestrator jobs, and the LLM Gateway replaced by the +contract ``MockResponsesServer``). +""" + +from __future__ import annotations + +import os +import textwrap +from pathlib import Path + +import pytest + +from ..contract.harness import BackgroundLoop, descendants, kill_pids +from ..contract.mock_responses import MockResponsesServer +from .fakes import FakeOrchestrator, mock_upstream_class + +AGENT_MAIN = textwrap.dedent( + """ + from pydantic import BaseModel + + from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool + + + class AddIn(BaseModel): + a: int + b: int + + + agent = UiPathCodexAgent( + model="mock-model", + prompt="PLEASE_CALL_DURABLE: add the numbers with a_plus_b.", + tools=[ + uipath_process_tool( + "a_plus_b", + folder_path="Shared/Durable", + description="Adds a and b.", + input_schema=AddIn, + ) + ], + durable_settle_timeout=20.0, + ) + """ +) + + +@pytest.fixture(autouse=True) +def _hermetic_env(monkeypatch): + monkeypatch.delenv("UIPATH_CODEX_BIN", raising=False) + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + for var in ( + "HTTPS_PROXY", + "HTTP_PROXY", + "ALL_PROXY", + "https_proxy", + "http_proxy", + "all_proxy", + ): + monkeypatch.setenv(var, "http://127.0.0.1:9") + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost") + monkeypatch.setenv("no_proxy", "127.0.0.1,localhost") + before = set(descendants(os.getpid())) + yield + kill_pids([p for p in descendants(os.getpid()) if p not in before]) + + +@pytest.fixture +async def mock_model(): + server = MockResponsesServer() + await server.start() + try: + yield server + finally: + await server.stop() + + +@pytest.fixture +def world(monkeypatch) -> FakeOrchestrator: + w = FakeOrchestrator() + import uipath.platform.resume_triggers._protocol as protocol + + import uipath_codex_sdk.tools as tools + + monkeypatch.setattr(tools, "_uipath_client", w.client) + monkeypatch.setattr(protocol, "UiPath", w.client) + return w + + +@pytest.fixture +def upstream(monkeypatch, mock_model): + import uipath_codex_sdk.runtime.gateway as gateway + + monkeypatch.setattr( + gateway, + "UpstreamRegistry", + mock_upstream_class(f"{mock_model.base_url}/responses"), + ) + monkeypatch.setattr(gateway, "get_default_client_settings", lambda: object()) + return mock_model + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch) -> Path: + proj = tmp_path / "project" + proj.mkdir() + (proj / "codex.json").write_text('{"agents": {"agent": "main.py:agent"}}') + (proj / "main.py").write_text(AGENT_MAIN) + monkeypatch.chdir(proj) + return proj + + +@pytest.fixture +def cli_env(tmp_path: Path, monkeypatch, world: FakeOrchestrator): + """A project for the real ``uipath`` CLI, which calls ``asyncio.run`` + itself: the mock model server runs on a background loop.""" + loop = BackgroundLoop() + server = MockResponsesServer() + loop.run(server.start()) + import uipath_codex_sdk.runtime.gateway as gateway + + monkeypatch.setattr( + gateway, "UpstreamRegistry", mock_upstream_class(f"{server.base_url}/responses") + ) + monkeypatch.setattr(gateway, "get_default_client_settings", lambda: object()) + proj = tmp_path / "cli_project" + proj.mkdir() + (proj / "codex.json").write_text('{"agents": {"agent": "main.py:agent"}}') + (proj / "main.py").write_text(AGENT_MAIN) + (proj / "uipath.json").write_text("{}") + (proj / "input.json").write_text("{}") + monkeypatch.chdir(proj) + for var in ("UIPATH_URL", "UIPATH_ACCESS_TOKEN", "UIPATH_TELEMETRY_ENABLED"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("UIPATH_TELEMETRY_ENABLED", "false") + try: + yield proj, server + finally: + loop.run(server.stop()) + loop.close() diff --git a/packages/uipath-codex-sdk/tests/durable/fakes.py b/packages/uipath-codex-sdk/tests/durable/fakes.py new file mode 100644 index 00000000..58f19d3a --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/fakes.py @@ -0,0 +1,887 @@ +"""Offline stand-ins for the UiPath tenant used by the durable-tool tests. + +* :class:`FakeOrchestrator` – processes/jobs services with scripted job states + (``processes.invoke_async``, ``jobs.retrieve_async``, + ``jobs.extract_output_async``), shared by our tools and by the platform's + ``UiPathResumeTriggerHandler``. +* :class:`FakeConnections` – the Integration Service ``connections`` service + (``invoke_activity_async``) with recorded calls, canned responses, an + injectable failure and a gate that holds calls in flight. +* :class:`MockUpstream` – replaces the gateway shim's ``UpstreamRegistry`` so + model traffic goes to the contract ``MockResponsesServer`` instead of the + UiPath LLM Gateway. +""" + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +from uipath.platform.orchestrator import Job + +from ..contract.mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + message_item, +) + +TRIGGER = "PLEASE_CALL_DURABLE" +FOLDER_KEY = "11111111-2222-3333-4444-555555555555" + + +@dataclass +class Invocation: + name: str + input_arguments: dict[str, Any] | None + folder_path: str | None + run_as_me: bool | None + job_key: str + parent_span_id: str | None = None + attachments: list[Any] | None = None + + +@dataclass +class FakeOrchestrator: + jobs: dict[str, Job] = field(default_factory=dict) + invocations: list[Invocation] = field(default_factory=list) + retrieve_calls: int = 0 + fail_start: Exception | None = None + connections: FakeConnections = field(default_factory=lambda: FakeConnections()) + context_grounding: FakeContextGrounding = field( + default_factory=lambda: FakeContextGrounding() + ) + tasks: FakeTasks = field(default_factory=lambda: FakeTasks()) + attachments: FakeAttachments = field(default_factory=lambda: FakeAttachments()) + api_client: FakeApiClient = field(default_factory=lambda: FakeApiClient()) + documents: FakeDocuments = field(default_factory=lambda: FakeDocuments()) + + # --- control ----------------------------------------------------------- + def finish( + self, + key: str, + state: str = "Successful", + output: Any = None, + info: str | None = None, + ) -> Job: + job = self.jobs[key] + data = job.model_dump(by_alias=True) + data["State"] = state + data["Info"] = info + data["OutputArguments"] = json.dumps(output) if output is not None else None + data["EndTime"] = time.strftime("%Y-%m-%dT%H:%M:%SZ") + self.jobs[key] = Job.model_validate(data) + return self.jobs[key] + + def finish_all(self, state: str = "Successful", output_fn=None) -> None: + for inv in self.invocations: + out = output_fn(inv) if output_fn else None + self.finish(inv.job_key, state, out) + + # --- the client surface ------------------------------------------------ + def client(self) -> Any: + return _FakeUiPath(self) + + +class _FakeProcesses: + def __init__(self, world: FakeOrchestrator) -> None: + self._w = world + + async def invoke_async( + self, + name: str, + input_arguments: dict[str, Any] | None = None, + *, + folder_key: str | None = None, + folder_path: str | None = None, + run_as_me: bool | None = None, + **kwargs: Any, + ) -> Job: + if self._w.fail_start is not None: + raise self._w.fail_start + n = len(self._w.invocations) + 1 + key = f"job-{n}-{uuid.uuid4().hex[:6]}" + job = Job.model_validate( + { + "Id": n, + "Key": key, + "State": "Pending", + "FolderKey": FOLDER_KEY, + "InputArguments": json.dumps(input_arguments or {}), + } + ) + self._w.jobs[key] = job + self._w.invocations.append( + Invocation( + name, + input_arguments, + folder_path, + run_as_me, + key, + kwargs.get("parent_span_id"), + kwargs.get("attachments"), + ) + ) + return job + + +class _FakeJobs: + def __init__(self, world: FakeOrchestrator) -> None: + self._w = world + + async def retrieve_async(self, job_key: str, **kwargs: Any) -> Job: + self._w.retrieve_calls += 1 + return self._w.jobs[job_key] + + async def extract_output_async(self, job: Job) -> str | None: + return job.output_arguments + + async def link_attachment_async(self, **kwargs: Any) -> None: + self._w.attachments.links.append(kwargs) + + +class _FakeFolders: + async def retrieve_key_async(self, *, folder_path: str) -> str | None: + return INDEX_FOLDER_KEY + + def retrieve_key(self, *, folder_path: str) -> str | None: + raise AssertionError(f"blocking folder lookup of {folder_path!r}") + + +@dataclass +class FakeAttachments: + """Uploaded attachments (``upload_async``) and job links.""" + + uploads: list[dict[str, Any]] = field(default_factory=list) + links: list[dict[str, Any]] = field(default_factory=list) + downloads: list[str] = field(default_factory=list) + fail_upload: int = 0 + + async def download_async( + self, *, key: Any, destination_path: str, **kwargs: Any + ) -> str: + Path(destination_path).write_text(f"document {key}") + self.downloads.append(str(key)) + return destination_path + + async def upload_async(self, **kwargs: Any) -> uuid.UUID: + if self.fail_upload: + self.fail_upload -= 1 + raise RuntimeError("upload failed") + content = Path(kwargs["source_path"]).read_text() + key = uuid.uuid4() + self.uploads.append({**kwargs, "content": content, "key": key}) + return key + + +@dataclass +@dataclass +class FakeDocuments: + """IXP extractions and Validation Station tasks with scripted states.""" + + PROJECT_ID = "ixp-project-1" + extractions: dict[str, dict[str, Any]] = field(default_factory=dict) + validations: dict[str, dict[str, Any]] = field(default_factory=dict) + starts: list[dict[str, Any]] = field(default_factory=list) + validation_starts: list[dict[str, Any]] = field(default_factory=list) + fail_start: Exception | None = None + + @staticmethod + def projection(total: str = "4200") -> list[dict[str, Any]]: + return [ + { + "fieldGroupName": "Invoice", + "fieldValues": [ + { + "id": "total", + "name": "Total", + "value": total, + "unformattedValue": total, + "confidence": 0.98, + "ocrConfidence": 0.99, + "type": "Number", + } + ], + } + ] + + async def start_ixp_extraction_async(self, **kwargs: Any) -> Any: + from uipath.platform.documents import StartExtractionResponse + + if self.fail_start is not None: + raise self.fail_start + op = f"ext-{len(self.starts) + 1}-{uuid.uuid4().hex[:6]}" + content = Path(kwargs["file_path"]).read_text() + self.starts.append({**kwargs, "content": content, "operation_id": op}) + self.extractions[op] = {"status": "Running", "tag": kwargs["tag"]} + return StartExtractionResponse.model_validate( + { + "operationId": op, + "documentId": f"doc-{op}", + "projectId": self.PROJECT_ID, + "tag": kwargs["tag"], + } + ) + + def finish_extraction(self, op: str, status: str = "Succeeded") -> None: + self.extractions[op]["status"] = status + + def extraction_response(self, op: str) -> dict[str, Any]: + return { + "extractionResult": { + "DocumentId": f"doc-{op}", + "ResultsVersion": 1, + "ResultsDocument": {"Fields": [{"FieldId": "total", "Value": "4200"}]}, + }, + "projectId": self.PROJECT_ID, + "projectType": "IXP", + "tag": self.extractions[op]["tag"], + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "dataProjection": self.projection(), + } + + async def retrieve_ixp_extraction_result_async( + self, project_id: str, tag: str, operation_id: str + ) -> Any: + from uipath.platform.documents import ExtractionResponseIXP + from uipath.platform.errors import ( + OperationFailedException, + OperationNotCompleteException, + ) + + status = self.extractions[operation_id]["status"] + if status in ("NotStarted", "Running"): + raise OperationNotCompleteException( + operation_id=operation_id, status=status + ) + if status != "Succeeded": + raise OperationFailedException( + operation_id=operation_id, status=status, error="extraction failed" + ) + return ExtractionResponseIXP.model_validate( + self.extraction_response(operation_id) + ) + + async def create_validate_extraction_action_async(self, **kwargs: Any) -> Any: + from uipath.platform.documents import ValidateExtractionAction + + op = f"val-{len(self.validation_starts) + 1}-{uuid.uuid4().hex[:6]}" + self.validation_starts.append({**kwargs, "operation_id": op}) + self.validations[op] = {"status": "Unassigned", "data": {}} + extraction = kwargs["extraction_response"] + return ValidateExtractionAction.model_validate( + { + "actionData": { + "status": "Unassigned", + "taskUrl": f"https://t/tasks/{op}", + }, + "actionStatus": "Unassigned", + "projectId": extraction.project_id, + "projectType": "IXP", + "tag": extraction.tag, + "operationId": op, + "extractorId": None, + "documentTypeId": extraction.document_type_id, + } + ) + + def complete_validation( + self, op: str, total: str = "4250", rejection: str | None = None + ) -> None: + data: dict[str, Any] = {} + if rejection is not None: + data["documentRejectionDetails"] = {"reason": rejection} + self.validations[op] = {"status": "Completed", "data": data, "total": total} + + async def retrieve_ixp_extraction_validation_result_async( + self, project_id: str, tag: str, operation_id: str + ) -> Any: + from uipath.platform.documents import ValidateExtractionAction + + task = self.validations[operation_id] + action_data: dict[str, Any] = {"data": task["data"]} + if task["status"] != "Deleted": + action_data["status"] = task["status"] + return ValidateExtractionAction.model_validate( + { + "actionData": action_data, + "actionStatus": task["status"], + "projectId": project_id, + "projectType": "IXP", + "tag": tag, + "operationId": operation_id, + "extractorId": None, + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "dataProjection": self.projection(task.get("total", "4200")), + } + ) + + +@dataclass +class FakeApiClient: + requests: list[dict[str, Any]] = field(default_factory=list) + + async def request_async(self, method: str, url: str = "", **kwargs: Any) -> Any: + self.requests.append({"method": method, "url": url, **kwargs}) + return httpx.Response(201, json={}, request=httpx.Request(method, "https://t")) + + +class _FakeUiPath: + def __init__(self, world: FakeOrchestrator) -> None: + self.processes = _FakeProcesses(world) + self.jobs = _FakeJobs(world) + self.connections = world.connections + self.context_grounding = world.context_grounding + self.folders = _FakeFolders() + self.tasks = world.tasks + self.attachments = world.attachments + self.documents = world.documents + self.api_client = world.api_client + + +INDEX_FOLDER_KEY = "99999999-8888-7777-6666-555555555555" + + +@dataclass +class DeepRagStart: + name: str + index_name: str | None + prompt: str + glob_pattern: str | None + citation_mode: Any + folder_key: str | None + folder_path: str | None + id: str + attachments: list[str] | None = None + + +@dataclass +class FakeTasks: + """Action Center tasks with scripted states (create / assign / retrieve).""" + + created: list[dict[str, Any]] = field(default_factory=list) + assignments: list[dict[str, Any]] = field(default_factory=list) + tasks: dict[str, dict[str, Any]] = field(default_factory=dict) + retrieve_calls: list[dict[str, Any]] = field(default_factory=list) + fail_create: Exception | None = None + fail_assign: Exception | None = None + + def complete( + self, key: str, action: str = "Approve", data: dict[str, Any] | None = None + ) -> None: + task = self.tasks[key] + task["status"] = 2 + task["action"] = action + task["data"] = {**(task.get("data") or {}), **(data or {})} + task["completedByUser"] = {"emailAddress": "reviewer@example.com"} + + def delete(self, key: str) -> None: + self.tasks[key]["isDeleted"] = True + + async def create_async(self, title: str, data: Any = None, **kwargs: Any) -> Any: + from uipath.platform.action_center import Task + + if self.fail_create is not None: + raise self.fail_create + n = len(self.created) + 1 + key = f"task-key-{n}-{uuid.uuid4().hex[:6]}" + self.created.append({"title": title, "data": data, **kwargs, "key": key}) + self.tasks[key] = { + "id": 1000 + n, + "key": key, + "title": title, + "status": 0, + "data": dict(data or {}), + "isDeleted": False, + } + return Task.model_validate(self.tasks[key]) + + async def request_async(self, method: str, url: Any, **kwargs: Any) -> Any: + if self.fail_assign is not None: + raise self.fail_assign + self.assignments.append({"method": method, "url": str(url), **kwargs}) + return httpx.Response(200, json={}, request=httpx.Request(method, "https://t")) + + async def retrieve_async(self, action_key: str, **kwargs: Any) -> Any: + from uipath.platform.action_center import Task + + self.retrieve_calls.append({"key": action_key, **kwargs}) + return Task.model_validate(self.tasks[action_key]) + + +@dataclass +class FakeContextGrounding: + """DeepRAG tasks with scripted states (start / retrieve by id).""" + + starts: list[DeepRagStart] = field(default_factory=list) + tasks: dict[str, dict[str, Any]] = field(default_factory=dict) + batch_starts: list[dict[str, Any]] = field(default_factory=list) + batch_tasks: dict[str, dict[str, Any]] = field(default_factory=dict) + downloads: list[str] = field(default_factory=list) + status_reads: list[str] = field(default_factory=list) + result_requests: list[str] = field(default_factory=list) + retrieve_calls: int = 0 + fail_start: Exception | None = None + + def finish( + self, + task_id: str, + status: str = "Successful", + text: str = "Acme Corp owes $4,200 [1].", + failure_reason: str | None = None, + ) -> None: + task = self.tasks[task_id] + task["lastDeepRagStatus"] = status + if status == "Successful": + task["content"] = { + "text": text, + "citations": [ + { + "ordinal": 1, + "pageNumber": 1, + "source": "invoice.jpg", + "reference": "https://ref/1", + } + ], + } + task["failureReason"] = failure_reason + + async def start_deep_rag_async(self, **kwargs: Any) -> Any: + from uipath.platform.context_grounding import DeepRagCreationResponse + + if self.fail_start is not None: + raise self.fail_start + task_id = f"dr-{len(self.starts) + 1}-{uuid.uuid4().hex[:6]}" + self.starts.append( + DeepRagStart( + name=kwargs["name"], + index_name=kwargs.get("index_name"), + prompt=kwargs["prompt"], + glob_pattern=kwargs.get("glob_pattern"), + citation_mode=kwargs["citation_mode"], + folder_key=kwargs.get("folder_key"), + folder_path=kwargs.get("folder_path"), + id=task_id, + attachments=kwargs.get("attachments"), + ) + ) + self.tasks[task_id] = { + "id": task_id, + "name": kwargs["name"], + "createdDate": "2026-10-13T00:00:00Z", + "lastDeepRagStatus": "Queued", + "content": None, + } + return DeepRagCreationResponse.model_validate(self.tasks[task_id]) + + async def start_deep_rag_from_attachments_async(self, **kwargs: Any) -> Any: + return await self.start_deep_rag_async(**kwargs) + + # --- Batch Transform ------------------------------------------------ + + async def start_batch_transform_from_attachment_async(self, **kwargs: Any) -> Any: + from uipath.platform.context_grounding import BatchTransformCreationResponse + + if self.fail_start is not None: + raise self.fail_start + task_id = f"bt-{len(self.batch_starts) + 1}-{uuid.uuid4().hex[:6]}" + self.batch_starts.append({**kwargs, "id": task_id}) + self.batch_tasks[task_id] = {"status": "Queued", "csv": None} + return BatchTransformCreationResponse.model_validate( + {"id": task_id, "lastBatchRagStatus": "Queued"} + ) + + def finish_batch( + self, task_id: str, status: str = "Successful", csv: str = "a,b\n1,2\n" + ) -> None: + self.batch_tasks[task_id] = {"status": status, "csv": csv} + + async def retrieve_batch_transform_async(self, id: str, **kwargs: Any) -> Any: + from types import SimpleNamespace + + self.status_reads.append(id) + return SimpleNamespace( + id=id, last_batch_rag_status=self.batch_tasks[id]["status"] + ) + + def _batch_transform_get_read_uri_spec(self, id: str) -> Any: + from types import SimpleNamespace + + return SimpleNamespace(method="GET", endpoint=f"read-uri/{id}", headers={}) + + def _batch_transform_download_blob_spec(self, id: str) -> Any: + from types import SimpleNamespace + + return SimpleNamespace(method="GET", endpoint=f"download-blob/{id}", headers={}) + + async def request_async(self, method: str, endpoint: str, **kwargs: Any) -> Any: + """The read-URI and authenticated download endpoints.""" + kind, task_id = endpoint.split("/", 1) + self.result_requests.append(kind) + request = httpx.Request(method, f"https://t/{endpoint}") + if kind == "read-uri": + # A storage host the test cannot reach (as a serverless robot). + return httpx.Response( + 200, + json={ + "uri": "https://unreachable.blob.invalid/r.csv", + "isEncrypted": False, + }, + request=request, + ) + return httpx.Response( + 200, content=self.batch_tasks[task_id]["csv"].encode(), request=request + ) + + async def download_batch_transform_result_async( + self, id: str, destination_path: str, **kwargs: Any + ) -> None: + from pathlib import Path + + from uipath.platform.errors import ( + BatchTransformFailedException, + BatchTransformNotCompleteException, + ) + + self.downloads.append(destination_path) + task = self.batch_tasks[id] + if task["status"] == "Failed": + raise BatchTransformFailedException(batch_transform_id=id) + if task["status"] != "Successful": + raise BatchTransformNotCompleteException( + batch_transform_id=id, status=task["status"] + ) + Path(destination_path).parent.mkdir(parents=True, exist_ok=True) + Path(destination_path).write_text(task["csv"]) + + async def retrieve_deep_rag_async(self, id: str, **kwargs: Any) -> Any: + from uipath.platform.context_grounding import DeepRagResponse + + self.retrieve_calls += 1 + return DeepRagResponse.model_validate(self.tasks[id]) + + +@dataclass +class ActivityInvocation: + activity_metadata: Any + connection_id: str + activity_input: dict[str, Any] + + +class FakeConnections: + """``connections.invoke_activity_async`` of the Integration Service.""" + + def __init__(self) -> None: + self.invocations: list[ActivityInvocation] = [] + #: Response by ``object_path``; ``{"ok": True}`` when not listed. + self.responses: dict[str, Any] = {} + self.fail: BaseException | None = None + #: When set, calls wait on it (held in flight) before answering. + self.gate: asyncio.Event | None = None + self.started = asyncio.Event() + + async def invoke_activity_async( + self, + activity_metadata: Any, + connection_id: str, + activity_input: dict[str, Any], + ) -> Any: + self.invocations.append( + ActivityInvocation(activity_metadata, connection_id, activity_input) + ) + self.started.set() + if self.gate is not None: + await self.gate.wait() + if self.fail is not None: + raise self.fail + return self.responses.get(activity_metadata.object_path, {"ok": True}) + + +def enriched( + status: int, code: str | None = None, message: str = "bad request" +) -> Exception: + """A real ``EnrichedException`` for an elements_ call, with a JSON body.""" + from uipath.platform.errors import EnrichedException + + body: dict[str, Any] = {"message": message} + if code is not None: + body["providerErrorCode"] = code + request = httpx.Request( + "POST", "https://cloud.test/org/tenant/elements_/v3/element/instances/c/x" + ) + response = httpx.Response( + status, + request=request, + content=json.dumps(body).encode(), + headers={"content-type": "application/json"}, + ) + error = httpx.HTTPStatusError("boom", request=request, response=response) + return EnrichedException(error) + + +# -------------------------------------------------------------------------- +# model side +# -------------------------------------------------------------------------- +class _RewriteToResponses(httpx.AsyncBaseTransport): + """The shim posts to ``/``; the mock serves ``/v1/responses``.""" + + def __init__(self, base: str) -> None: + self._inner = httpx.AsyncHTTPTransport() + self._base = httpx.URL(base) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + request.url = self._base + return await self._inner.handle_async_request(request) + + async def aclose(self) -> None: + await self._inner.aclose() + + +def mock_upstream_class(responses_url: str) -> type: + class MockUpstream: + def __init__(self, settings: Any) -> None: + self._client = httpx.AsyncClient( + base_url=responses_url, + transport=_RewriteToResponses(responses_url), + timeout=60, + ) + + async def get(self, model: str) -> httpx.AsyncClient: + return self._client + + async def aclose(self) -> None: + await self._client.aclose() + + return MockUpstream + + +class AddScript: + """Mock model: on the trigger prompt call ``a_plus_b`` ``n`` times in one + response; afterwards answer with every tool output it was given.""" + + def __init__(self, n_calls: int = 1, tool: str = "a_plus_b") -> None: + self.call_ids = [f"call_{uuid.uuid4().hex[:24]}" for _ in range(n_calls)] + self.tool = tool + self.advertised: tuple[str | None, str | None] | None = None + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + user_text = json.dumps( + [ + it + for it in items + if it.get("type") == "message" and it.get("role") == "user" + ] + ) + if TRIGGER in user_text and not (seen & set(self.call_ids)): + name, ns = find_tool(advertised_tools(body), self.tool) + self.advertised = (name, ns) + if name: + return [ + function_call_item(cid, name, {"a": 17 + i, "b": 25}, ns) + for i, cid in enumerate(self.call_ids) + ] + outputs = { + it["call_id"]: it.get("output") + for it in items + if it.get("type") == "function_call_output" + } + return [message_item("FINAL " + json.dumps(outputs, sort_keys=True), idx)] + + +class SiblingScript(AddScript): + """One durable call plus a shell sibling (``exec_command``) in the same + response, the sibling either after or before the durable call.""" + + SIBLING_ID = "call_sibling00000000000000" + + def __init__(self, sibling_after: bool) -> None: + super().__init__(n_calls=1) + self.sibling_after = sibling_after + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if self.call_ids[0] in seen: + return [message_item("FINAL", idx)] + name, ns = find_tool(advertised_tools(body), self.tool) + if not name: + return None + durable = function_call_item(self.call_ids[0], name, {"a": 1, "b": 2}, ns) + sibling = function_call_item( + self.SIBLING_ID, "exec_command", {"cmd": "echo sibling-ran"} + ) + return [durable, sibling] if self.sibling_after else [sibling, durable] + + +class ChainScript(AddScript): + """Call ``a_plus_b`` once, then again after the first result arrives.""" + + def __init__(self) -> None: + super().__init__(n_calls=2) + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + outputs = { + it["call_id"]: it.get("output") + for it in items + if it.get("type") == "function_call_output" + } + name, ns = find_tool(advertised_tools(body), self.tool) + for i, cid in enumerate(self.call_ids): + if cid not in outputs: + seen = { + it.get("call_id") + for it in items + if it.get("type") == "function_call" + } + if cid in seen or not name: + return None + return [function_call_item(cid, name, {"a": 17 + i, "b": 25}, ns)] + return [message_item("FINAL " + json.dumps(outputs, sort_keys=True), idx)] + + +IS_TOOL = "genai_web_search" +IS_CALL_ID = "call_genaiwebsearch0000000000" + + +def _outputs(items: list[dict[str, Any]]) -> dict[str, Any]: + return { + it["call_id"]: it.get("output") + for it in items + if it.get("type") == "function_call_output" + } + + +class IntegrationScript: + """Mock model: call the IS tool once, then answer with every tool output.""" + + def __init__(self, tool: str = IS_TOOL, args: dict[str, Any] | None = None) -> None: + self.tool = tool + self.args = args if args is not None else {"query": "uipath codex"} + self.call_id = IS_CALL_ID + self.advertised: tuple[str | None, str | None] | None = None + self.tool_specs: list[dict[str, Any]] = [] + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if self.call_id not in seen: + tools = advertised_tools(body) + name, ns = find_tool(tools, self.tool) + self.advertised = (name, ns) + self.tool_specs = tools + if name: + return [function_call_item(self.call_id, name, self.args, ns)] + return [ + message_item("FINAL " + json.dumps(_outputs(items), sort_keys=True), idx) + ] + + +class MixedScript(AddScript): + """A durable ``a_plus_b`` call and the IS call in one response.""" + + def __init__(self, is_after: bool, is_args: dict[str, Any] | None = None) -> None: + super().__init__(n_calls=1) + self.is_after = is_after + self.is_args = is_args if is_args is not None else {"query": "uipath codex"} + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if self.call_ids[0] in seen: + return [ + message_item( + "FINAL " + json.dumps(_outputs(items), sort_keys=True), idx + ) + ] + tools = advertised_tools(body) + name, ns = find_tool(tools, self.tool) + is_name, is_ns = find_tool(tools, IS_TOOL) + if not name or not is_name: + return None + durable = function_call_item(self.call_ids[0], name, {"a": 1, "b": 2}, ns) + integration = function_call_item(IS_CALL_ID, is_name, self.is_args, is_ns) + return [durable, integration] if self.is_after else [integration, durable] + + +class DeepRagScript(AddScript): + """Mock model: call the DeepRAG tool once with a query, then answer with + every tool output it was given.""" + + QUERY = "How much does Acme Corp owe, and by when? Cite the invoices." + + def __init__(self, tool: str = "Invoice_Research") -> None: + super().__init__(n_calls=1, tool=tool) + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if self.call_ids[0] not in seen: + name, ns = find_tool(advertised_tools(body), self.tool) + self.advertised = (name, ns) + if name: + return [ + function_call_item( + self.call_ids[0], name, {"query": self.QUERY}, ns + ) + ] + return [ + message_item("FINAL " + json.dumps(_outputs(items), sort_keys=True), idx) + ] + + +class DeepRagAttachmentScript(AddScript): + """Mock model: call the DeepRAG attachment tool once with ``attachment`` + (``{"ID": attachment_id}``) and a query, then answer with every output.""" + + QUERY = "What does the contract say about termination? Cite the clauses." + + def __init__(self, attachment_id: str, tool: str = "DeepRAG") -> None: + super().__init__(n_calls=1, tool=tool) + self.attachment_id = attachment_id + self.bodies: list[dict[str, Any]] = [] + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + self.bodies.append(body) + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if self.call_ids[0] not in seen: + name, ns = find_tool(advertised_tools(body), self.tool) + self.advertised = (name, ns) + if name: + args = {"query": self.QUERY, "attachment": {"ID": self.attachment_id}} + return [function_call_item(self.call_ids[0], name, args, ns)] + return [ + message_item("FINAL " + json.dumps(_outputs(items), sort_keys=True), idx) + ] + + +class EscalationScript(AddScript): + """Mock model: call the escalation tool once with an expense, then answer + with every tool output it was given.""" + + ARGS = {"amount": 1250.0, "reason": "Team offsite"} + + def __init__(self, tool: str = "escalate_ExpenseApproval") -> None: + super().__init__(n_calls=1, tool=tool) + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if self.call_ids[0] not in seen: + name, ns = find_tool(advertised_tools(body), self.tool) + self.advertised = (name, ns) + if name: + return [function_call_item(self.call_ids[0], name, self.ARGS, ns)] + return [ + message_item("FINAL " + json.dumps(_outputs(items), sort_keys=True), idx) + ] diff --git a/packages/uipath-codex-sdk/tests/durable/test_attachments_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_attachments_e2e.py new file mode 100644 index 00000000..53b5858e --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_attachments_e2e.py @@ -0,0 +1,415 @@ +"""Offline end-to-end tests of job attachments. + +The real factory → runtime → bundled codex → gateway shim (mock model) → +``uipath_tools`` host, with Analyze Files' blob storage and LLM Gateway call +faked, and fake Orchestrator jobs for the durable process tool. +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import textwrap +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Callable + +import httpx +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +import uipath_codex_sdk.attachment_tools as at +from uipath_codex_sdk.attachments import not_found_message +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.runtime import ATTACHMENT_INSTRUCTIONS + +from ..contract.mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + last_user_text, + message_item, +) + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +DOC_ID = "dddddddd-0000-0000-0000-000000000001" +REPORT_ID = "dddddddd-0000-0000-0000-000000000002" +UNKNOWN_ID = "dddddddd-0000-0000-0000-00000000ffff" +DOC = {"ID": DOC_ID, "FullName": "invoice.pdf", "MimeType": "application/pdf"} +REPORT = {"ID": REPORT_ID, "FullName": "summary.md", "MimeType": "text/markdown"} +CALL_ID = "call_attachments0000000000001" + +ANALYZE_MAIN = textwrap.dedent( + """ + from pydantic import BaseModel + + from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_analyze_files_tool, + ) + + + class Input(BaseModel): + document: UiPathAttachment + question: str + + + class Output(BaseModel): + answer: str + source: UiPathAttachment | None = None + + + agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + output_schema=Output, + prompt="Answer '{question}' about the document.", + tools=[uipath_analyze_files_tool()], + ) + """ +) + +ANALYZE_PREFILLED_MAIN = textwrap.dedent( + """ + from pydantic import BaseModel + + from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + analyze_files_tool_from_resource, + ) + from uipath_codex_sdk.attachment_tools import analyze_files_input_schema + + + class Input(BaseModel): + documents: list[UiPathAttachment] + question: str + + + class Output(BaseModel): + answer: str + + + analyze = analyze_files_tool_from_resource( + { + "$resourceType": "tool", + "type": "Internal", + "name": "Analyze Files", + "description": "Analyze the attached files.", + "inputSchema": analyze_files_input_schema(), + "outputSchema": {"type": "object", "properties": {}}, + "properties": {"toolType": "analyze-attachments"}, + "settings": {}, + "argumentProperties": { + "attachments": { + "variant": "argument", + "argumentPath": "$.documents", + "isSensitive": False, + } + }, + } + ) + + agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + output_schema=Output, + prompt="Answer '{question}' about the documents.", + tools=[analyze], + ) + """ +) + +PROCESS_MAIN = textwrap.dedent( + """ + from pydantic import BaseModel + + from uipath_codex_sdk import UiPathAttachment, UiPathCodexAgent, uipath_process_tool + + + class Input(BaseModel): + document: UiPathAttachment + + + class SummarizeOut(BaseModel): + report: UiPathAttachment + + + class Output(BaseModel): + original: UiPathAttachment + report: UiPathAttachment + + + agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + output_schema=Output, + prompt="PLEASE_CALL_DURABLE: summarize {document} with Summarize.", + tools=[ + uipath_process_tool( + "Summarize", + folder_path="Shared", + input_schema=Input, + output_schema=SummarizeOut, + ) + ], + durable_settle_timeout=20.0, + ) + """ +) + + +class Script: + """Call ``tool`` once with ``args``; then answer ``final(outputs)``.""" + + def __init__( + self, tool: str, args: dict[str, Any], final: Callable[[dict[str, Any]], str] + ) -> None: + self.tool, self.args, self.final = tool, args, final + self.prompts: list[str] = [] + self.bodies: list[dict[str, Any]] = [] + self.tool_specs: list[dict[str, Any]] = [] + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + self.bodies.append(body) + self.prompts.append(last_user_text(body)) + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if CALL_ID not in seen: + tools = advertised_tools(body) + name, ns = find_tool(tools, self.tool) + self.tool_specs = tools + if name: + return [function_call_item(CALL_ID, name, self.args, ns)] + outputs = { + it["call_id"]: _output_text(it.get("output")) + for it in items + if it.get("type") == "function_call_output" + } + return [message_item(self.final(outputs), idx)] + + +def _output_text(output: Any) -> Any: + """An MCP tool's result text (Codex sends a list after a ``Wall time`` part).""" + if isinstance(output, list): + texts = [p.get("text", "") for p in output if isinstance(p, dict)] + return texts[-1] if texts else "" + return output + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +@pytest.fixture +def files(monkeypatch): + """Analyze Files' blob storage and LLM Gateway.""" + state = SimpleNamespace(requests=[], size=1) + + async def blob_uri(*, key): + return SimpleNamespace(uri=f"https://blob.test/{key}", name="x") + + def blob(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"%PDF-1.7 total 42" * state.size) + + class Gateway: + async def request(self, method, path, *, headers, content): + state.requests.append(json.loads(content)) + return httpx.Response( + 200, + json={ + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Total: 42"}], + } + ] + }, + ) + + async def aclose(self) -> None: + pass + + async def gateway_client(model: str) -> Gateway: + return Gateway() + + monkeypatch.setattr( + at, + "_uipath_client", + lambda: SimpleNamespace( + attachments=SimpleNamespace(get_blob_file_access_uri_async=blob_uri) + ), + ) + monkeypatch.setattr( + at, + "_http_client", + lambda: httpx.AsyncClient(transport=httpx.MockTransport(blob)), + ) + monkeypatch.setattr(at, "_gateway_client", gateway_client) + return state + + +async def _run(project: Path, input, *, resume: bool = False): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + return await asyncio.wait_for( + runtime.execute(input, UiPathExecuteOptions(resume=resume)), 120 + ) + finally: + await runtime.dispose() + await factory.dispose() + + +def _answer(outputs: dict[str, Any]) -> str: + return json.dumps({"answer": outputs.get(CALL_ID) or "", "source": {"ID": DOC_ID}}) + + +async def test_analyze_files_with_an_input_attachment(project, upstream, files, spans): + (project / "main.py").write_text(ANALYZE_MAIN) + script = Script( + "Analyze_Files", + {"attachments": [{"ID": DOC_ID}], "analysisTask": "What is the total?"}, + _answer, + ) + upstream.script = script + result = await _run(project, {"document": DOC, "question": "the total"}) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert result.output == { + "answer": json.dumps({"analysisResult": "Total: 42"}), + "source": {**DOC, "Metadata": None}, # filled in from the known ID + } + # The template does not show the document: it follows as a block. + prompt = script.prompts[0] + assert prompt.startswith("Answer 'the total' about the document.\n\n") + assert "Input attachments (JobAttachment objects):" in prompt and DOC_ID in prompt + assert ATTACHMENT_INSTRUCTIONS in json.dumps(script.bodies[0]) + # Codex keeps the job-attachment reference in the tool's parameters. + assert "job-attachment" in json.dumps(script.tool_specs) + [request] = files.requests + parts = request["input"][0]["content"] + assert parts[0] == {"type": "input_text", "text": "What is the total?"} + assert parts[1]["type"] == "input_file" and parts[1]["filename"] == "invoice-pdf" + + finished = {s.name: s for s in spans.get_finished_spans()} + run = finished["Agent run - agent"] + assert json.loads(run.attributes["attachments"])[0]["id"] == DOC_ID + output = finished["Agent output"] + assert json.loads(output.attributes["attachments"])[0]["direction"] == 2 + call = finished["Tool call - Analyze_Files"] + assert call.attributes["toolType"] == "Internal" + assert json.loads(call.attributes["attachments"])[0]["id"] == DOC_ID + internal = finished["Analyze Files"] + assert internal.parent.span_id == call.context.span_id + llm = [ + s + for s in spans.get_finished_spans() + if s.name == "LLM call" and s.parent.span_id == internal.context.span_id + ] + assert len(llm) == 1 and "attachments" in llm[0].attributes + + +async def test_unknown_attachment_id_is_a_tool_error(project, upstream, files): + (project / "main.py").write_text(ANALYZE_MAIN) + script = Script( + "Analyze_Files", + {"attachments": [{"ID": UNKNOWN_ID}], "analysisTask": "x"}, + lambda outputs: json.dumps({"answer": outputs.get(CALL_ID) or ""}), + ) + upstream.script = script + result = await _run(project, {"document": DOC, "question": "q"}) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not_found_message(UNKNOWN_ID) in result.output["answer"] + assert files.requests == [] + + +async def test_an_input_attachment_wins_over_an_unknown_model_id( + project, upstream, files, spans +): + # The agent input fills ``attachments`` (argumentProperties): as in + # LangChain, the model's unknown ID it replaces is not checked. + (project / "main.py").write_text(ANALYZE_PREFILLED_MAIN) + script = Script( + "Analyze_Files", + {"attachments": [{"ID": UNKNOWN_ID}], "analysisTask": "What is the total?"}, + lambda outputs: json.dumps({"answer": outputs.get(CALL_ID) or ""}), + ) + upstream.script = script + result = await _run(project, {"documents": [DOC], "question": "the total"}) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert result.output == {"answer": json.dumps({"analysisResult": "Total: 42"})} + [request] = files.requests + assert request["input"][0]["content"][1]["filename"] == "invoice-pdf" + call = {s.name: s for s in spans.get_finished_spans()}["Tool call - Analyze_Files"] + assert json.loads(call.attributes["input"])["attachments"] == [DOC] + assert [a["id"] for a in json.loads(call.attributes["attachments"])] == [DOC_ID] + + +async def test_a_file_over_the_limit_fails_the_run(project, upstream, files): + (project / "main.py").write_text(ANALYZE_MAIN) + files.size = (at.MAX_FILE_BYTES // len(b"%PDF-1.7 total 42")) + 2 + upstream.script = Script( + "Analyze_Files", + {"attachments": [{"ID": DOC_ID}], "analysisTask": "x"}, + _answer, + ) + with pytest.raises(Exception) as info: + await _run(project, {"document": DOC, "question": "q"}) + error = info.value + assert "FILE_ERROR" in str(getattr(error, "error_info", None) or error) + assert files.requests == [] + + +async def test_process_tool_attachments_survive_a_suspension(project, upstream, world): + (project / "main.py").write_text(PROCESS_MAIN) + script = Script( + "Summarize", + {"document": {"ID": DOC_ID}}, + lambda outputs: json.dumps( + {"original": {"ID": DOC_ID}, "report": {"ID": REPORT_ID}} + ), + ) + upstream.script = script + result = await _run(project, {"document": DOC}) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + [invocation] = world.invocations + assert [str(a.id) for a in invocation.attachments] == [DOC_ID] + assert invocation.input_arguments["document"]["FullName"] == "invoice.pdf" + + world.finish(invocation.job_key, output={"report": REPORT}) + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + # The input's attachment (from the saved run input) and the job's + # (harvested by the tool's output schema) are both known. + assert result.output == { + "original": {**DOC, "Metadata": None}, + "report": {**REPORT, "Metadata": None}, + } diff --git a/packages/uipath-codex-sdk/tests/durable/test_batch_transform_attachments_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_batch_transform_attachments_e2e.py new file mode 100644 index 00000000..1a5c54a4 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_batch_transform_attachments_e2e.py @@ -0,0 +1,260 @@ +"""Offline end-to-end tests of Batch Transform on a job attachment. + +The real factory, runtime, bundled codex, gateway shim, tool host and +``UiPathResumeTriggerHandler`` (its BatchRag reader downloads the result), +with a mock model and fake context grounding, attachments and jobs services. +""" + +from __future__ import annotations + +import json +import shutil +import uuid +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .._typing import sqlite_storage +from ..contract.mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + message_item, +) + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +JOB_KEY = "abababab-0000-0000-0000-000000000001" +FOLDER_KEY = "cdcdcdcd-0000-0000-0000-000000000001" +CSV_ID = "efefefef-0000-0000-0000-000000000001" +CSV = {"ID": CSV_ID, "FullName": "reviews.csv", "MimeType": "text/csv"} +CALL_ID = "call_batchtransform00000000001" +QUERY = "Classify the sentiment of each review." + +MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_batch_transform_attachments_tool, +) + + +class Input(BaseModel): + reviews: UiPathAttachment + + +class Output(BaseModel): + enriched: UiPathAttachment + + +agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + output_schema=Output, + prompt="PLEASE_CALL_DURABLE: enrich {reviews}.", + tools=[ + uipath_batch_transform_attachments_tool( + "Enrich Reviews", + output_columns=[("sentiment", "positive, neutral or negative")], + ) + ], + durable_settle_timeout=20.0, +) +""" + + +class Script: + """Call the tool once; then return the result attachment's ID as output.""" + + def __init__(self, attachment_id: str = CSV_ID) -> None: + self.attachment_id = attachment_id + self.outputs: dict[str, Any] = {} + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + if CALL_ID not in seen: + name, ns = find_tool(advertised_tools(body), "Enrich_Reviews") + if name: + args = {"query": QUERY, "attachment": {"ID": self.attachment_id}} + return [function_call_item(CALL_ID, name, args, ns)] + for it in items: + if it.get("type") == "function_call_output": + self.outputs[it["call_id"]] = it.get("output") + text: Any = self.outputs.get(CALL_ID) # None: no output yet + try: + result = json.loads(text)["result"] + answer = {"enriched": {"ID": result["ID"]}} + except (TypeError, ValueError, KeyError): + answer = {"enriched": {"ID": "00000000-0000-0000-0000-000000000000"}} + return [message_item(json.dumps(answer), idx)] + + +@pytest.fixture +def project(project: Path, monkeypatch) -> Path: + (project / "main.py").write_text(MAIN) + monkeypatch.setenv("UIPATH_FOLDER_KEY", FOLDER_KEY) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + return project + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _external_triggers(world) -> list[dict[str, Any]]: + return [r for r in world.api_client.requests if "SaveExternalTrigger" in r["url"]] + + +async def test_batch_transform_suspends_uploads_and_returns_the_result( + project, upstream, world, spans +): + upstream.script = Script() + cg = world.context_grounding + + # --- run: one start on the attachment, then suspend ----------------- + result, triggers = await _run(project, {"reviews": CSV}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + [start] = cg.batch_starts + assert start["attachment"] == CSV_ID and start["prompt"] == QUERY + assert [c.name for c in start["output_columns"]] == ["sentiment"] + assert (start["folder_key"], start["folder_path"]) == (FOLDER_KEY, None) + assert start["enable_web_search_grounding"] is False + summary = result.output[CALL_ID] + assert summary["type"] == "WaitBatchTransformFile" + assert summary["batch_transform_id"] == start["id"] + [trig] = triggers + assert trig.trigger_type == UiPathResumeTriggerType.BATCH_RAG + assert trig.trigger_name == UiPathResumeTriggerName.BATCH_RAG + assert (trig.item_key, trig.folder_key) == (start["id"], FOLDER_KEY) + assert trig.payload["destination_path"].endswith(f"{start['id']}.csv") + assert trig.payload["reader"] == "codex" # read by codex, not the platform + assert len(_external_triggers(world)) == 1 + + # --- still running: suspends again --------------------------------- + cg.batch_tasks[start["id"]]["status"] = "InProgress" + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + + # --- finished: downloaded, uploaded, linked, returned ---------------- + cg.finish_batch(start["id"], csv="review,sentiment\ngreat,positive\n") + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers and len(cg.batch_starts) == 1 + # The authenticated download: the storage URL is never needed. + assert cg.result_requests == ["download-blob"] + assert cg.downloads == [] # the platform's downloader was never used + [upload] = world.attachments.uploads + assert upload["name"] == "output.csv" + assert upload["content"] == "review,sentiment\ngreat,positive\n" + assert upload["folder_key"] == FOLDER_KEY + [link] = world.attachments.links + assert link["attachment_key"] == upload["key"] + assert link["job_key"] == uuid.UUID(JOB_KEY) + assert not Path(upload["source_path"]).exists() # cleaned up + # The result is known to the run: the model returned it by ID. + assert result.output == { + "enriched": { + "ID": str(upload["key"]), + "FullName": "output.csv", + "MimeType": "text/csv", + "Metadata": None, + } + } + + finished = spans.get_finished_spans() + [call] = [s for s in finished if s.name == "Tool call - Enrich_Reviews"] + [child] = [s for s in finished if s.name == "Enrich_Reviews"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert call.attributes["toolType"] == "ContextGrounding" + directions = [a["direction"] for a in json.loads(call.attributes["attachments"])] + assert directions == [1, 2] # the source in, the result out + attrs = child.attributes + assert attrs["span_type"] == "contextGroundingTool" + assert attrs["retrieval_mode"] == "BatchTransform" + assert attrs["query"] == QUERY + assert attrs["batch_transform_id"] == start["id"] + assert json.loads(attrs["output_columns"])[0]["name"] == "sentiment" + assert json.loads(attrs["results"]) == { + "id": str(upload["key"]), + "fileName": "output.csv", + "mimeType": "text/csv", + } + child_attachments = json.loads(attrs["attachments"]) + assert [(a["id"], a["direction"]) for a in child_attachments] == [ + (CSV_ID, 1), + (str(upload["key"]), 2), + ] + + +async def test_failed_batch_transform_faults_the_run(project, upstream, world): + upstream.script = Script() + cg = world.context_grounding + result, _ = await _run(project, {"reviews": CSV}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + [start] = cg.batch_starts + cg.finish_batch(start["id"], status="Failed") + _new_robot(project) + with pytest.raises(Exception) as info: + await _run(project, None, resume=True) + assert start["id"] in str(getattr(info.value, "error_info", None) or info.value) + assert world.attachments.uploads == [] + + +async def test_unknown_attachment_starts_nothing(project, upstream, world): + unknown = "efefefef-0000-0000-0000-00000000ffff" + upstream.script = Script(attachment_id=unknown) + # The output then names an unknown ID too: the run fails at the end. + with pytest.raises(Exception, match="OUTPUT_INVALID|does not know"): + await _run(project, {"reviews": CSV}, resume=False) + assert world.context_grounding.batch_starts == [] diff --git a/packages/uipath-codex-sdk/tests/durable/test_cli.py b/packages/uipath-codex-sdk/tests/durable/test_cli.py new file mode 100644 index 00000000..72471699 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_cli.py @@ -0,0 +1,116 @@ +"""The documented local flow through the real ``uipath`` CLI, offline. + +``uipath run agent --file input.json`` suspends (``__uipath/output.json`` lists +a JOB_RAW resume trigger), then ``uipath run agent --resume`` completes once +the job is done. The CLI calls ``asyncio.run`` itself, so these tests are sync +and the mock model server runs on a background loop. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner +from uipath.core.triggers import UiPathResumeTriggerName + +from ..contract.mock_responses import input_items +from .fakes import AddScript + +pytestmark = pytest.mark.contract + + +def _uipath(*args: str): + from uipath._cli import cli + + return CliRunner().invoke(cli, list(args), catch_exceptions=False) + + +def _output(proj: Path) -> dict: + return json.loads((proj / "__uipath" / "output.json").read_text()) + + +def test_run_then_resume_via_cli(cli_env, world, monkeypatch): + """Deployed shape: a job key is set, so output.json carries the triggers + and the runtime id is the job key (the same key is resumed).""" + monkeypatch.setenv("UIPATH_JOB_KEY", "parent-job-key") + proj, server = cli_env + script = AddScript(n_calls=1) + server.script = script + cid = script.call_ids[0] + + res = _uipath("run", "agent", "--file", "input.json") + assert res.exit_code == 0, res.output + out = _output(proj) + assert out["status"].lower() == "suspended", out + triggers = out.get("resumeTriggers") or ( + [out["resume"]] if out.get("resume") else [] + ) + assert triggers, out + assert triggers[0].get("interruptId") == cid, triggers + assert triggers[0].get("itemKey") == world.invocations[0].job_key + + # still running: --resume suspends again + res = _uipath("run", "agent", "--resume") + assert res.exit_code == 0, res.output + assert _output(proj)["status"].lower() == "suspended" + + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + shutil.rmtree(proj / "__uipath" / "codex_home") # only state.db carries over + res = _uipath("run", "agent", "--resume") + assert res.exit_code == 0, res.output + out = _output(proj) + assert out["status"].lower() == "successful", out + expected = json.dumps({"sum": 42}) + assert out["output"] == {"output": "FINAL " + json.dumps({cid: expected})} + last = server.snapshot()[-1].body + outs = [ + it + for it in input_items(last) + if it.get("type") == "function_call_output" and it.get("call_id") == cid + ] + assert [o["output"] for o in outs] == [expected] + + +def _db_triggers(proj: Path) -> list[tuple[str, dict]]: + import sqlite3 + + conn = sqlite3.connect(str(proj / "__uipath" / "state.db")) + try: + rows = conn.execute( + "SELECT interrupt_id, data FROM __uipath_resume_triggers" + ).fetchall() + finally: + conn.close() + return [(r[0], json.loads(r[1])) for r in rows] + + +def test_local_run_then_resume_via_cli(cli_env, world, monkeypatch): + """Local shape (README): no job key, the trigger lives in state.db.""" + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + proj, server = cli_env + script = AddScript(n_calls=1) + server.script = script + cid = script.call_ids[0] + + res = _uipath("run", "agent", "--file", "input.json") + assert res.exit_code == 0, res.output + [(interrupt_id, data)] = _db_triggers(proj) + assert interrupt_id == cid + assert data["trigger_name"] == UiPathResumeTriggerName.JOB_RAW.value # 'JobRaw' + assert data["item_key"] == world.invocations[0].job_key + + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + res = _uipath("run", "agent", "--resume") + assert res.exit_code == 0, res.output + assert _db_triggers(proj) == [] + assert "FINAL" in res.output + last = server.snapshot()[-1].body + outs = [ + it["output"] + for it in input_items(last) + if it.get("type") == "function_call_output" and it.get("call_id") == cid + ] + assert outs == [json.dumps({"sum": 42})] diff --git a/packages/uipath-codex-sdk/tests/durable/test_deep_rag_attachments_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_deep_rag_attachments_e2e.py new file mode 100644 index 00000000..79cf5d85 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_deep_rag_attachments_e2e.py @@ -0,0 +1,243 @@ +"""Offline end-to-end tests of DeepRAG on a job attachment (suspend → resume). + +The real factory, runtime, bundled codex, gateway shim, tool host and +``UiPathResumeTriggerHandler``, with a mock model and a fake +``context_grounding`` service whose DeepRAG tasks the test finishes. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.attachments import not_found_message +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.runtime import ATTACHMENT_INSTRUCTIONS + +from .._typing import sqlite_storage +from .fakes import DeepRagAttachmentScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +JOB_FOLDER_KEY = "12121212-3434-5656-7878-909090909090" +DOC_ID = "eeeeeeee-0000-0000-0000-000000000001" +DOC = {"ID": DOC_ID, "FullName": "contract.pdf", "MimeType": "application/pdf"} + +MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_deep_rag_attachments_tool, +) + + +class Input(BaseModel): + document: UiPathAttachment + + +agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + prompt="PLEASE_CALL_DURABLE: research {document} with DeepRAG.", + tools=[uipath_deep_rag_attachments_tool("DeepRAG")], + durable_settle_timeout=20.0, +) +""" + + +@pytest.fixture +def project(project: Path, monkeypatch) -> Path: + (project / "main.py").write_text(MAIN) + monkeypatch.setenv("UIPATH_FOLDER_KEY", JOB_FOLDER_KEY) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + return project + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _final_outputs(result) -> dict: + text = result.output["output"] + assert text.startswith("FINAL "), text + return json.loads(text[len("FINAL ") :]) + + +async def test_deep_rag_on_an_attachment_suspends_then_resumes( + project, upstream, world, spans +): + script = DeepRagAttachmentScript(DOC_ID) + upstream.script = script + cid = script.call_ids[0] + cg = world.context_grounding + + # --- run: one start on the attachment, then suspend ----------------- + result, triggers = await _run(project, {"document": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert script.advertised == ("DeepRAG", "mcp__uipath_tools") + assert ATTACHMENT_INSTRUCTIONS in json.dumps(script.bodies[0]) + [start] = cg.starts + assert start.attachments == [DOC_ID] and start.index_name is None + assert start.prompt == DeepRagAttachmentScript.QUERY + assert (start.folder_key, start.folder_path) == (JOB_FOLDER_KEY, None) + summary = result.output[cid] + assert summary["type"] == "WaitDeepRagRaw" + assert summary["deep_rag_id"] == start.id + [trig] = triggers + assert trig.trigger_type == UiPathResumeTriggerType.DEEP_RAG + assert trig.trigger_name == UiPathResumeTriggerName.DEEP_RAG_RAW + assert (trig.item_key, trig.folder_key) == (start.id, JOB_FOLDER_KEY) + names = [s.name for s in spans.get_finished_spans()] + assert "Tool call - DeepRAG" not in names and "DeepRAG" not in names + + # --- still running: suspends again, nothing restarted --------------- + n_requests = len(upstream.snapshot()) + cg.tasks[start.id]["lastDeepRagStatus"] = "InProgress" + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert len(upstream.snapshot()) == n_requests + + # --- finished: the model gets the answer ---------------------------- + cg.finish(start.id, text="Either party may terminate with 30 days notice [1].") + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers and len(cg.starts) == 1 + answer = json.loads(_final_outputs(result)[cid]) + assert answer["text"] == "Either party may terminate with 30 days notice [1]." + assert answer["deepRagId"] == start.id + + finished = spans.get_finished_spans() + [call] = [s for s in finished if s.name == "Tool call - DeepRAG"] + [child] = [s for s in finished if s.name == "DeepRAG"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert call.attributes["toolType"] == "Internal" + assert json.loads(call.attributes["attachments"])[0]["id"] == DOC_ID + attrs = child.attributes + assert attrs["span_type"] == "toolCall" and attrs["toolType"] == "Internal" + assert attrs["tool.name"] == "DeepRAG" + # One call to the tool-call evaluators, across the suspension too. + from uipath.eval._helpers.evaluators_helpers import extract_tool_calls_names + + assert extract_tool_calls_names(finished) == ["DeepRAG"] + assert attrs["deep_rag_id"] == start.id # kept across the suspension + assert json.loads(attrs["input"])["attachment"]["FullName"] == "contract.pdf" + assert json.loads(attrs["output"])["deepRagId"] == start.id + assert child.status.status_code.name == "OK" + + +async def test_unknown_attachment_starts_nothing(project, upstream, world): + unknown = "eeeeeeee-0000-0000-0000-00000000ffff" + script = DeepRagAttachmentScript(unknown) + upstream.script = script + result, triggers = await _run(project, {"document": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers and world.context_grounding.starts == [] + assert not_found_message(unknown) in json.dumps(_final_outputs(result)) + + +async def test_start_error_goes_back_to_the_model(project, upstream, world): + import httpx + from uipath.platform.errors import EnrichedException + + request = httpx.Request("POST", "https://t/ecs_/v2/deeprag/create") + response = httpx.Response( + 400, json={"message": "No valid files to use"}, request=request + ) + world.context_grounding.fail_start = EnrichedException( + httpx.HTTPStatusError("400", request=request, response=response) + ) + upstream.script = DeepRagAttachmentScript(DOC_ID) + result, triggers = await _run(project, {"document": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + assert "Could not start DeepRAG on attachment 'contract.pdf' (HTTP 400)" in ( + json.dumps(_final_outputs(result)) + ) + + +FROM_INPUT_MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_deep_rag_attachments_tool, +) + + +class Input(BaseModel): + other: UiPathAttachment + document: UiPathAttachment | None = None + + +agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + prompt="PLEASE_CALL_DURABLE: research {other} with DeepRAG.", + tools=[uipath_deep_rag_attachments_tool("DeepRAG", attachment_from_input="document")], + durable_settle_timeout=20.0, +) +""" + + +async def test_attachment_from_input_falls_back_to_the_model(project, upstream, world): + # The agent input has no 'document': the model is shown the field (as + # optional, with a note) and the attachment it passes is used. + (project / "main.py").write_text(FROM_INPUT_MAIN) + script = DeepRagAttachmentScript(DOC_ID) + upstream.script = script + result, triggers = await _run(project, {"other": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert "Set from the agent input 'document'" in json.dumps(script.bodies[0]) + [start] = world.context_grounding.starts + assert start.attachments == [DOC_ID] + [trig] = triggers + assert trig.item_key == start.id diff --git a/packages/uipath-codex-sdk/tests/durable/test_deep_rag_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_deep_rag_e2e.py new file mode 100644 index 00000000..ca25de24 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_deep_rag_e2e.py @@ -0,0 +1,231 @@ +"""Offline end-to-end tests of the DeepRAG durable tool (suspend → resume). + +Same set-up as ``test_e2e``: the real factory, runtime, bundled codex, gateway +shim, tool host and ``UiPathResumeTriggerHandler``, with a mock model and a +fake ``context_grounding`` service whose DeepRAG tasks the test finishes. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .._typing import sqlite_storage +from ..contract.mock_responses import input_items, summarize_input +from .fakes import INDEX_FOLDER_KEY, DeepRagScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" + +DEEP_RAG_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent, uipath_deep_rag_tool + +agent = UiPathCodexAgent( + model="mock-model", + prompt="PLEASE_CALL_DURABLE: research the invoices with Invoice_Research.", + tools=[ + uipath_deep_rag_tool( + "Invoice Research", + index_name="invoices", + folder_path="Coded", + description="Researches the company's invoices.", + ) + ], + durable_settle_timeout=20.0, +) +""" + + +@pytest.fixture +def project(project: Path) -> Path: + (project / "main.py").write_text(DEEP_RAG_MAIN) + return project + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _final_outputs(result) -> dict: + text = result.output["output"] + assert text.startswith("FINAL "), text + return json.loads(text[len("FINAL ") :]) + + +async def test_deep_rag_suspends_then_resumes_with_the_answer(project, upstream, world): + script = DeepRagScript() + upstream.script = script + cid = script.call_ids[0] + cg = world.context_grounding + + # --- run: starts the task and suspends on it -------------------------- + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert script.advertised == ("Invoice_Research", "mcp__uipath_tools") + [start] = cg.starts + assert start.index_name == "invoices" + assert start.prompt == DeepRagScript.QUERY + assert start.glob_pattern == "**/*" + assert start.citation_mode.value == "Inline" + assert (start.folder_key, start.folder_path) == (INDEX_FOLDER_KEY, None) + assert start.name.startswith("task-") + summary = result.output[cid] + assert summary["deep_rag_id"] == start.id + assert summary["deep_rag_status"] == "Queued" + assert summary["folder_key"] == INDEX_FOLDER_KEY + assert summary["type"] == "WaitDeepRagRaw" + [trig] = triggers + assert trig.interrupt_id == cid + assert trig.trigger_type == UiPathResumeTriggerType.DEEP_RAG + assert trig.trigger_name == UiPathResumeTriggerName.DEEP_RAG_RAW + assert trig.item_key == start.id + assert trig.folder_key == INDEX_FOLDER_KEY + n_requests = len(upstream.snapshot()) + + # --- still in progress: --resume suspends again ----------------------- + cg.tasks[start.id]["lastDeepRagStatus"] = "InProgress" + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.interrupt_id for t in triggers] == [cid] + assert len(upstream.snapshot()) == n_requests + + # --- finished: the model gets langchain's DeepRAG result -------------- + cg.finish(start.id) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + answer = json.loads(_final_outputs(result)[cid]) + assert answer == { + "text": "Acme Corp owes $4,200 [1].", + "citations": [ + { + "ordinal": 1, + "page_number": 1, + "source": "invoice.jpg", + "reference": "https://ref/1", + } + ], + "deepRagId": start.id, + } + assert len(cg.starts) == 1 # never started again + + +async def test_failed_deep_rag_is_a_result_not_a_fault(project, upstream, world): + script = DeepRagScript() + upstream.script = script + cid = script.call_ids[0] + cg = world.context_grounding + + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + [start] = cg.starts + cg.finish(start.id, "Failed", failure_reason="No documents matched.") + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert json.loads(_final_outputs(result)[cid]) == { + "status": "failed", + "deepRagId": start.id, + "error": "No documents matched.", + } + + +async def test_start_error_goes_back_to_the_model(project, upstream, world): + from uipath.platform.errors import ContextGroundingIndexNotFoundError + + script = DeepRagScript() + upstream.script = script + world.context_grounding.fail_start = ContextGroundingIndexNotFoundError("invoices") + + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + blob = json.dumps([summarize_input(r.body) for r in upstream.snapshot()]) + assert "Context grounding index 'invoices' was not found." in json.dumps( + [input_items(r.body) for r in upstream.snapshot()] + ), blob + + +async def test_deep_rag_spans_stay_open_across_the_suspension( + project, upstream, world, spans +): + script = DeepRagScript() + upstream.script = script + cg = world.context_grounding + + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + names = [s.name for s in spans.get_finished_spans()] + assert "Tool call - Invoice_Research" not in names + assert "Invoice Research" not in names # held open, like the Tool call + + [start] = cg.starts + cg.finish(start.id) + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + finished = spans.get_finished_spans() + [call] = [s for s in finished if s.name == "Tool call - Invoice_Research"] + [child] = [s for s in finished if s.name == "Invoice Research"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert call.attributes["toolType"] == "ContextGrounding" + assert call.attributes["context_retrieval_mode"] == "DeepRAG" + assert call.attributes["index_name"] == "invoices" + attrs = child.attributes + assert attrs["span_type"] == "contextGroundingTool" + assert attrs["retrieval_mode"] == "DeepRAG" + assert attrs["query"] == DeepRagScript.QUERY + assert attrs["citation_mode"] == "Inline" + assert attrs["deep_rag_id"] == start.id + assert json.loads(attrs["output"])["deepRagId"] == start.id + assert child.status.status_code.name == "OK" + # Both were started in the first execution and ended in the second. + assert child.start_time < call.end_time and child.end_time <= call.end_time diff --git a/packages/uipath-codex-sdk/tests/durable/test_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_e2e.py new file mode 100644 index 00000000..b19e1886 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_e2e.py @@ -0,0 +1,470 @@ +"""Offline end-to-end tests of durable process tools (suspend → resume). + +Drives the real factory → ``UiPathResumableRuntime`` → ``UiPathCodexRuntime`` +→ bundled codex 0.161 → gateway shim (mock model) + tool host, with fake +Orchestrator jobs. Each resume runs in a fresh factory with ``CODEX_HOME`` +deleted, as on another robot: only ``state.db`` carries over. +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +from pathlib import Path + +import pytest +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.runtime import CONTINUE_PROMPT, HERMETIC_OVERRIDES + +from .._typing import as_result, sqlite_storage +from ..contract.mock_responses import input_items, summarize_input +from .conftest import AGENT_MAIN +from .fakes import FOLDER_KEY, AddScript, ChainScript, SiblingScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" + + +def _context(project: Path, *, resume: bool) -> UiPathRuntimeContext: + return UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory(_context(project, resume=resume)) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + triggers = await storage.get_triggers(RUNTIME_ID) + return result, triggers + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + """A resumed job gets a fresh runtime dir except for state.db.""" + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _resume_request(mock, call_ids: list[str]): + """First model request that carries an output for every call id.""" + for req in mock.snapshot(): + outs = { + it.get("call_id") + for it in input_items(req.body) + if it.get("type", "").endswith("_output") + } + if set(call_ids) <= outs: + return req + return None + + +def _assert_paired(body: dict, call_id: str, expected_output: str) -> None: + items = input_items(body) + calls = [ + (i, it) + for i, it in enumerate(items) + if it.get("type") == "function_call" and it.get("call_id") == call_id + ] + outs = [ + (i, it) + for i, it in enumerate(items) + if it.get("type", "").endswith("_output") and it.get("call_id") == call_id + ] + summary = summarize_input(body) + assert len(calls) == 1, summary + assert len(outs) == 1, summary + (ci, call), (oi, out) = calls[0], outs[0] + assert call.get("name") == "a_plus_b", summary + assert call.get("namespace") == "mcp__uipath_tools", summary + assert out["type"] == "function_call_output", summary + assert out["output"] == expected_output, summary + assert oi > ci, summary + blob = json.dumps(items) + assert "turn_aborted" not in blob, summary + assert "aborted" not in json.dumps([o for _, o in outs]), summary + + +async def test_suspend_then_resume_successful(project, upstream, world): + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + + # --- run: suspends on the job -------------------------------------- + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert script.advertised == ("a_plus_b", "mcp__uipath_tools") + assert len(world.invocations) == 1 + inv = world.invocations[0] + assert inv.name == "a_plus_b" + assert inv.input_arguments == {"a": 17, "b": 25} + assert inv.folder_path == "Shared/Durable" + # The printed/output.json output is a slim summary; the trigger holds the job. + assert isinstance(result.output, dict) and list(result.output) == [cid] + assert result.output[cid] == { + "tool": "a_plus_b", + "arguments": {"a": 17, "b": 25}, + "job_key": inv.job_key, + "job_state": result.output[cid]["job_state"], + "folder_key": FOLDER_KEY, + "type": "WaitJobRaw", + } + assert triggers is not None and len(triggers) == 1 + trig = triggers[0] + assert trig.interrupt_id == cid + assert trig.trigger_type == UiPathResumeTriggerType.JOB + assert trig.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert trig.item_key == inv.job_key + assert trig.folder_key == FOLDER_KEY + n_requests = len(upstream.snapshot()) + + # --- the job is still running: --resume re-suspends ----------------- + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert triggers is not None and [t.interrupt_id for t in triggers] == [cid] + assert len(upstream.snapshot()) == n_requests # no codex run, no model call + + # --- the job finished: resume completes the run --------------------- + world.finish(inv.job_key, "Successful", {"sum": 42}) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + expected = json.dumps({"sum": 42}) + req = _resume_request(upstream, [cid]) + assert req is not None, [summarize_input(r.body) for r in upstream.snapshot()] + _assert_paired(req.body, cid, expected) + assert CONTINUE_PROMPT in json.dumps(input_items(req.body)) + assert result.output == {"output": "FINAL " + json.dumps({cid: expected})} + assert len(world.invocations) == 1 # the job was never started again + + +async def test_faulted_job_becomes_error_result(project, upstream, world): + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + world.finish(world.invocations[0].job_key, "Faulted", info="Division by zero") + + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + req = _resume_request(upstream, [cid]) + assert req is not None + out = next( + it + for it in input_items(req.body) + if it.get("type") == "function_call_output" and it.get("call_id") == cid + ) + payload = json.loads(out["output"]) + assert payload["status"] == "faulted" + assert payload["error"] == "Division by zero" + assert payload["job_key"] == world.invocations[0].job_key + + +async def test_two_parallel_calls(project, upstream, world): + script = AddScript(n_calls=2) + upstream.script = script + + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert len(world.invocations) == 2 + assert sorted(result.output) == sorted(script.call_ids) + assert triggers is not None + assert sorted(t.interrupt_id for t in triggers) == sorted(script.call_ids) + by_args = {inv.input_arguments["a"]: inv for inv in world.invocations} + + # only the first job is done: resume finishes it and waits for the other + first = by_args[17] + world.finish(first.job_key, "Successful", {"sum": 42}) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert triggers is not None and len(triggers) == 1 + + second = by_args[18] + world.finish(second.job_key, "Successful", {"sum": 43}) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + req = _resume_request(upstream, script.call_ids) + assert req is not None, [summarize_input(r.body) for r in upstream.snapshot()] + cid_of = { + json.loads(it["arguments"])["a"]: it["call_id"] + for it in input_items(req.body) + if it.get("type") == "function_call" + } + _assert_paired(req.body, cid_of[17], json.dumps({"sum": 42})) + _assert_paired(req.body, cid_of[18], json.dumps({"sum": 43})) + + +async def test_fast_path_completes_in_process(project, upstream, world, monkeypatch): + """A job that is already done when the run suspends is resumed in the same + process by ``UiPathResumableRuntime`` (re-entrant runtime, same CODEX_HOME).""" + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + + original = world.client + + def finishing_client(): + for inv in world.invocations: + if world.jobs[inv.job_key].state == "Pending": + world.finish(inv.job_key, "Successful", {"sum": 42}) + return original() + + import uipath.platform.resume_triggers._protocol as protocol + + monkeypatch.setattr(protocol, "UiPath", finishing_client) + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + req = _resume_request(upstream, [cid]) + assert req is not None + _assert_paired(req.body, cid, json.dumps({"sum": 42})) + + +async def test_explicit_resume_payload(project, upstream, world): + """``uipath run agent '{"sum": 7}' --resume`` answers the single call.""" + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + _new_robot(project) + result, triggers = await _run(project, {"sum": 7}, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + req = _resume_request(upstream, [cid]) + assert req is not None + _assert_paired(req.body, cid, json.dumps({"sum": 7})) + + +@pytest.mark.parametrize("sibling_after", [True, False], ids=["after", "before"]) +async def test_shell_sibling_is_paired_on_resume( + project, upstream, world, sibling_after +): + """A non-durable sibling in the same response as a held call. + + After the held call its output never reaches the rollout (outputs are + recorded in call order), so it is taken from the item/completed + notification and injected; before it, codex already persisted it. + Either way every call is answered exactly once and nothing is "aborted". + """ + script = SiblingScript(sibling_after=sibling_after) + upstream.script = script + cid, sib = script.call_ids[0], SiblingScript.SIBLING_ID + + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + world.finish(world.invocations[0].job_key, "Successful", {"sum": 3}) + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + + req = _resume_request(upstream, [cid, sib]) + assert req is not None, [summarize_input(r.body) for r in upstream.snapshot()] + _assert_paired(req.body, cid, json.dumps({"sum": 3})) + items = input_items(req.body) + sib_outs = [ + it + for it in items + if it.get("type") == "function_call_output" and it.get("call_id") == sib + ] + assert len(sib_outs) == 1, summarize_input(req.body) + assert "aborted" not in sib_outs[0]["output"] + assert "Not executed" not in sib_outs[0]["output"] + order = [ + it.get("call_id") for it in items if it.get("type") == "function_call_output" + ] + assert order == ([cid, sib] if sibling_after else [sib, cid]) + + +async def test_suspends_again_after_resume(project, upstream, world): + """The resumed turn calls the durable tool again: a second suspension.""" + script = ChainScript() + upstream.script = script + first, second = script.call_ids + + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert list(result.output) == [first] + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert list(result.output) == [second] + assert triggers is not None and [t.interrupt_id for t in triggers] == [second] + assert len(world.invocations) == 2 + world.finish(world.invocations[1].job_key, "Successful", {"sum": 43}) + + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + req = _resume_request(upstream, [first, second]) + assert req is not None + _assert_paired(req.body, first, json.dumps({"sum": 42})) + _assert_paired(req.body, second, json.dumps({"sum": 43})) + assert result.output == { + "output": "FINAL " + + json.dumps( + {first: json.dumps({"sum": 42}), second: json.dumps({"sum": 43})}, + sort_keys=True, + ) + } + + +PLAIN_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent + +agent = UiPathCodexAgent(model="mock-model", prompt="Say hi to {name}.") +""" + + +async def test_agent_without_durable_tools_is_unchanged( + project, upstream, world, monkeypatch, tmp_path +): + """No durable tools: codex keeps its own CODEX_HOME and gets no hermetic + overrides, exactly as before durable tools existed.""" + import openai_codex + + user_home = tmp_path / "user_codex_home" + user_home.mkdir() + monkeypatch.setenv("CODEX_HOME", str(user_home)) + configs: list = [] + real_config = openai_codex.CodexConfig + + def spy_config(*args, **kwargs): + configs.append(kwargs) + return real_config(*args, **kwargs) + + monkeypatch.setattr(openai_codex, "CodexConfig", spy_config) + (project / "main.py").write_text(PLAIN_MAIN) + upstream.script = AddScript() + result, triggers = await _run(project, {"name": "Ada"}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert result.output == {"output": "FINAL {}"} + assert not triggers + reqs = upstream.snapshot() + assert len(reqs) == 1 + body = reqs[0].body + assert "Say hi to Ada." in json.dumps(input_items(body)) + assert "long-running" not in json.dumps(input_items(body)) + assert all(t.get("name") != "mcp__uipath_tools" for t in body.get("tools") or []) + assert not world.invocations + # codex resolved CODEX_HOME itself: the user's, not a run-scoped one + [config] = configs + assert "CODEX_HOME" not in config["env"] + assert not set(HERMETIC_OVERRIDES) & set(config["config_overrides"]) + assert list(user_home.glob("sessions/**/rollout-*.jsonl")) + assert not (project / "__uipath" / "codex_home").exists() + + +async def test_missing_process_handle_fails_instead_of_hanging( + project, upstream, world, monkeypatch +): + """If the SDK's private process handle disappears (an SDK upgrade), the + run fails with SUSPEND_KILL_UNAVAILABLE rather than hanging or writing an + 'aborted' history.""" + from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError + from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime + + real = UiPathCodexRuntime._kill_app_server + + def no_handle(codex, suspend): + return real(object(), suspend) + + monkeypatch.setattr(UiPathCodexRuntime, "_kill_app_server", staticmethod(no_handle)) + upstream.script = AddScript(n_calls=1) + with pytest.raises(UiPathCodexRuntimeError) as exc: + await asyncio.wait_for(_run(project, {}, resume=False), 60) + assert "SUSPEND_KILL_UNAVAILABLE" in str(exc.value.error_info.code) + + +async def test_code_mode_only_model_still_calls_durable_tools_directly( + project, upstream, world +): + """Regression: codex's bundled catalog marks e.g. gpt-5.6-terra as + ``tool_mode: code_mode_only`` and defers MCP tools behind tool_search, so + the model reached durable tools only from inside an ``exec`` JavaScript + cell, as nested calls that never reach the rollout and cannot suspend. + """ + (project / "main.py").write_text( + AGENT_MAIN.replace('model="mock-model"', 'model="gpt-5.6-terra"') + ) + script = AddScript(n_calls=1) + upstream.script = script + + result, triggers = await _run(project, {}, resume=False) + + first = upstream.snapshot()[0].body + # code mode stays on for codex's own tools; ours are offered directly + # (omit_tools_from=["deferred", "code_mode"]) and called as a top-level + # function_call, so the call can be held and suspended. + assert "ALL_TOOLS" in json.dumps(first), "expected a code_mode_only model" + assert script.advertised == ("a_plus_b", "mcp__uipath_tools") + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert triggers is not None and len(triggers) == 1 + assert len(world.invocations) == 1 + + +async def test_resume_streams_the_durable_tool_result(project, upstream, world): + """On resume the finished durable call shows up as a ``tool`` state event + with its arguments and the job result, before the model continues.""" + from uipath.runtime import UiPathStreamOptions + from uipath.runtime.events import UiPathRuntimeStateEvent + + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + inv = world.invocations[0] + world.finish(inv.job_key, "Successful", {"sum": 42}) + _new_robot(project) + + factory = UiPathCodexRuntimeFactory(_context(project, resume=True)) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + events = [] + try: + async for ev in runtime.stream(None, UiPathStreamOptions(resume=True)): + events.append(ev) + finally: + await runtime.dispose() + await factory.dispose() + + resumed = [ + e + for e in events + if isinstance(e, UiPathRuntimeStateEvent) + and e.node_name == "tools" + and (e.payload or {}).get("resumed") + ] + assert len(resumed) == 1, [getattr(e, "payload", e) for e in events] + payload = resumed[0].payload + assert payload is not None + assert payload["tool"] == "a_plus_b" + assert payload["arguments"] == {"a": 17, "b": 25} + assert payload["result"] == {"sum": 42} + assert payload["status"] == "completed" + assert payload["job_key"] == inv.job_key + metadata = resumed[0].metadata + assert metadata is not None + assert metadata["call_id"] == cid + assert as_result(events[-1]).status == UiPathRuntimeStatus.SUCCESSFUL diff --git a/packages/uipath-codex-sdk/tests/durable/test_escalation_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_escalation_e2e.py new file mode 100644 index 00000000..59cf73b2 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_escalation_e2e.py @@ -0,0 +1,294 @@ +"""Offline end-to-end tests of the Action Center escalation tool (suspend → resume). + +Same set-up as ``test_e2e``: the real factory, runtime, bundled codex, gateway +shim, tool host and ``UiPathResumeTriggerHandler``, with a mock model and a +fake ``tasks`` service whose tasks the test completes, rejects or deletes. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .._typing import sqlite_storage +from .fakes import EscalationScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" + +ESCALATION_MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import UiPathCodexAgent, uipath_escalation_tool + + +class Review(BaseModel): + amount: float + reason: str + source: str + + +class Decision(BaseModel): + Comment: str + + +agent = UiPathCodexAgent( + model="mock-model", + prompt="PLEASE_CALL_DURABLE: ask a manager to approve the expense.", + tools=[ + uipath_escalation_tool( + "ExpenseApproval", + folder_path="Shared", + input_schema=Review, + output_schema=Decision, + title="Expense approval", + assignee="manager@example.com", + static_args={"source": "codex-agent"}, + outcome_mapping={"Reject": "end"}, + ) + ], + durable_settle_timeout=20.0, +) +""" + + +@pytest.fixture +def project(project: Path) -> Path: + (project / "main.py").write_text(ESCALATION_MAIN) + return project + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _final_outputs(result) -> dict: + text = result.output["output"] + assert text.startswith("FINAL "), text + return json.loads(text[len("FINAL ") :]) + + +async def test_escalation_suspends_then_resumes_with_the_decision( + project, upstream, world +): + script = EscalationScript() + upstream.script = script + cid = script.call_ids[0] + tasks = world.tasks + + # --- run: creates the task, assigns it, suspends ---------------------- + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert script.advertised == ("escalate_ExpenseApproval", "mcp__uipath_tools") + [created] = tasks.created + assert created["title"] == "Expense approval" + assert created["data"] == { + "amount": 1250.0, + "reason": "Team offsite", + "source": "codex-agent", # pre-filled, hidden from the model + } + assert created["app_name"] == "ExpenseApproval" + assert (created["app_folder_path"], created["app_folder_key"]) == ("Shared", None) + assert "recipient" not in created and "assignee" not in created # assigned after + [assignment] = tasks.assignments + assert assignment["json"]["taskAssignments"][0]["UserNameOrEmail"] == ( + "manager@example.com" + ) + task = tasks.tasks[created["key"]] + summary = result.output[cid] + assert summary["task_id"] == task["id"] + assert summary["task_key"] == created["key"] + assert summary["app_name"] == "ExpenseApproval" + assert summary["type"] == "WaitEscalation" + [trig] = triggers + assert trig.trigger_type == UiPathResumeTriggerType.TASK + assert trig.trigger_name == UiPathResumeTriggerName.ESCALATION + assert (trig.item_key, trig.folder_path) == (created["key"], "Shared") + + # --- still open: --resume suspends again ------------------------------ + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.interrupt_id for t in triggers] == [cid] + + # --- approved: the model gets langchain's escalation result ----------- + tasks.complete(created["key"], "Approve", {"Comment": "Fine by me"}) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + assert json.loads(_final_outputs(result)[cid]) == { + "status": "completed", + "outcome": "Approve", + "output": {"Comment": "Fine by me"}, # only the output schema's fields + "task_id": task["id"], + "assigned_to": "reviewer@example.com", + } + assert len(tasks.created) == 1 # never created again + + +@pytest.mark.parametrize("ending", ["reject", "delete"]) +async def test_end_outcome_and_deleted_task_end_the_run( + project, upstream, world, spans, ending +): + from uipath_codex_sdk.runtime.errors import UiPathRunTerminatedError + + script = EscalationScript() + upstream.script = script + tasks = world.tasks + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + [created] = tasks.created + if ending == "reject": + tasks.complete(created["key"], "Reject", {"Comment": "Too expensive"}) + else: + tasks.delete(created["key"]) + _new_robot(project) + with pytest.raises(Exception) as info: + await _run(project, None, resume=True) + error = info.value + assert "TERMINATION_ESCALATION_REJECTED" in str( + getattr(error, "error_info", None) or error + ) or isinstance(error, UiPathRunTerminatedError) + finished = spans.get_finished_spans() + [call] = [s for s in finished if s.name == "Tool call - escalate_ExpenseApproval"] + [child] = [s for s in finished if s.name == "ExpenseApproval"] + assert call.status.status_code.name == "OK" # closed once, with the outcome + result_attr = json.loads(child.attributes["result"]) + if ending == "reject": + assert result_attr == { + "outcome": "Reject", + "output": {"Comment": "Too expensive"}, + } + else: + assert result_attr == { + "outcome": "The escalation task was deleted", + "output": None, + } + + +async def test_assignment_failure_still_waits_on_the_task( + project, upstream, world, spans +): + script = EscalationScript() + upstream.script = script + world.tasks.fail_assign = RuntimeError("user not found") + result, triggers = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert len(world.tasks.created) == 1 and len(triggers) == 1 + [created] = world.tasks.created + world.tasks.complete(created["key"], "Approve", {"Comment": "ok"}) + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + [child] = [s for s in spans.get_finished_spans() if s.name == "ExpenseApproval"] + assert child.attributes["assignmentError"] == "user not found" + assert "assignedTo" not in child.attributes + + +async def test_typed_resume_answer(project, upstream, world): + script = EscalationScript() + upstream.script = script + cid = script.call_ids[0] + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + reads = len(world.tasks.retrieve_calls) # the wrapper's check after suspending + _new_robot(project) + result, _ = await _run( + project, {"action": "Approve", "data": {"Comment": "typed"}}, resume=True + ) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + answer = json.loads(_final_outputs(result)[cid]) + assert (answer["outcome"], answer["output"]) == ("Approve", {"Comment": "typed"}) + assert len(world.tasks.retrieve_calls) == reads # the resume did not read it + + +async def test_escalation_spans_stay_open_across_the_suspension( + project, upstream, world, spans, monkeypatch +): + monkeypatch.setenv("UIPATH_URL", "https://cloud.test/org/tenant") + script = EscalationScript() + upstream.script = script + result, _ = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + names = [s.name for s in spans.get_finished_spans()] + assert "Tool call - escalate_ExpenseApproval" not in names + assert "ExpenseApproval" not in names + [created] = world.tasks.created + task_id = world.tasks.tasks[created["key"]]["id"] + world.tasks.complete(created["key"], "Approve", {"Comment": "Fine"}) + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + finished = spans.get_finished_spans() + [call] = [s for s in finished if s.name == "Tool call - escalate_ExpenseApproval"] + [child] = [s for s in finished if s.name == "ExpenseApproval"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert call.attributes["toolType"] == "Escalation" + assert call.attributes["tool.kind"] == "escalation" + # The span shows low-code's {outcome, output}; the evaluators' text is full. + assert json.loads(call.attributes["output"]) == { + "outcome": "Approve", + "output": {"Comment": "Fine"}, + } + content = json.loads(json.loads(call.attributes["output.value"])["content"]) + assert content["task_id"] == task_id and content["status"] == "completed" + attrs = child.attributes + assert attrs["span_type"] == "escalationTool" + assert attrs["channelType"] == "actionCenter" + assert attrs["taskId"] == str(task_id) + assert attrs["taskUrl"] == f"https://cloud.test/org/tenant/actions_/tasks/{task_id}" + assert attrs["assignedTo"] == "manager@example.com" + assert json.loads(attrs["arguments"])["source"] == "codex-agent" + assert json.loads(attrs["result"]) == { + "outcome": "Approve", + "output": {"Comment": "Fine"}, + } + assert "output" not in attrs + assert child.end_time <= call.end_time diff --git a/packages/uipath-codex-sdk/tests/durable/test_integration_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_integration_e2e.py new file mode 100644 index 00000000..4941d5e8 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_integration_e2e.py @@ -0,0 +1,319 @@ +"""Offline end-to-end tests of Integration Service (immediate) tools. + +The real factory → runtime → bundled codex 0.161 → gateway shim (mock model) +→ ``uipath_tools`` MCP host, with a fake ``connections`` service standing in +for Integration Service (and fake Orchestrator jobs for the durable sibling). +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import textwrap +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.session_store import CodexSessionStore +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage +from uipath_codex_sdk.runtime.tool_host import UNKNOWN_OUTCOME_OUTPUT + +from ..contract.mock_responses import input_items, summarize_input +from .fakes import ( + IS_CALL_ID, + IS_TOOL, + IntegrationScript, + MixedScript, + enriched, +) + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +CONN = "22222222-aaaa-bbbb-cccc-000000000002" +RESULT = {"formattedResults": "UiPath Codex SDK — docs", "results": [{"url": "u"}]} + +IS_TOOL_DECL = f""" +uipath_integration_tool( + "Web Search", + tool_name="{IS_TOOL}", + connection_id="{CONN}", + connector_key="uipath-uipath-airdk", + object_name="v2::webSearch", + tool_path="/v2/webSearch", + input_schema=SearchIn, + static_args={{"provider": "GoogleCustomSearch"}}, + description="Searches the public web.", +) +""" + + +def _main(*, durable: bool, settle: float = 20.0) -> str: + tools = [IS_TOOL_DECL.strip()] + if durable: + tools.insert( + 0, + 'uipath_process_tool("a_plus_b", folder_path="Shared/Durable", ' + 'description="Adds a and b.", input_schema=AddIn)', + ) + return ( + textwrap.dedent( + """ + from pydantic import BaseModel + + from uipath_codex_sdk import ( + UiPathCodexAgent, + uipath_integration_tool, + uipath_process_tool, + ) + + + class AddIn(BaseModel): + a: int + b: int + + + class SearchIn(BaseModel): + query: str + + + agent = UiPathCodexAgent( + model="mock-model", + prompt="PLEASE_CALL_DURABLE: search the web.", + tools=[TOOLS], + durable_settle_timeout=SETTLE, + ) + """ + ) + .replace("TOOLS", ",\n ".join(tools)) + .replace("SETTLE", repr(settle)) + ) + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + return await asyncio.wait_for( + runtime.execute(input, UiPathExecuteOptions(resume=resume)), 120 + ) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _text(output: Any) -> str: + """A function_call_output as text. + + Codex hands a live MCP result to the model as content parts (a ``Wall + time`` header, then the tool text); an injected output is a string. + """ + if isinstance(output, list): + texts = [p.get("text", "") for p in output if isinstance(p, dict)] + return texts[-1] if texts else "" + return output if isinstance(output, str) else json.dumps(output) + + +def _final(result) -> dict[str, str]: + text = result.output["output"] + assert text.startswith("FINAL "), text + return {k: _text(v) for k, v in json.loads(text[len("FINAL ") :]).items()} + + +def _tool_spec(tools: list[dict[str, Any]], needle: str) -> dict[str, Any] | None: + for t in tools: + for sub in [t, *(t.get("tools") or [])]: + if needle in (sub.get("name") or ""): + return sub + return None + + +def _outputs_for(body: dict[str, Any], call_id: str) -> list[dict[str, Any]]: + return [ + it + for it in input_items(body) + if it.get("type", "").endswith("_output") and it.get("call_id") == call_id + ] + + +async def _session_rows(project: Path) -> tuple[Any, Any]: + db = project / "__uipath" / "state.db" + if not db.exists(): + return {}, None + storage = SqliteResumableStorage(str(db)) + try: + store = CodexSessionStore(storage, RUNTIME_ID) + return await store.get_pending(), await store.get_thread() + finally: + await storage.dispose() + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor( # type: ignore[attr-defined] + SimpleSpanProcessor(exporter) + ) + yield exporter + exporter.shutdown() + + +# -------------------------------------------------------------------------- +async def test_integration_tool_only_agent(project, upstream, world, spans): + (project / "main.py").write_text(_main(durable=False)) + world.connections.responses["/v2/webSearch"] = RESULT + script = IntegrationScript() + upstream.script = script + + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + name, ns = script.advertised or (None, None) + assert name == IS_TOOL and ns == "mcp__uipath_tools", script.advertised + spec = _tool_spec(script.tool_specs, IS_TOOL) + assert spec is not None + schema_text = json.dumps(spec) + assert "query" in schema_text and "provider" not in schema_text + assert "[long-running]" not in schema_text + + assert json.loads(_final(result)[IS_CALL_ID]) == RESULT + [inv] = world.connections.invocations + assert inv.connection_id == CONN + assert inv.activity_input == { + "query": "uipath codex", + "provider": "GoogleCustomSearch", + } + md = inv.activity_metadata + assert md.object_path == "/v2/webSearch" and md.method_name == "POST" + assert md.parameter_location_info.body_fields == ["query", "provider"] + assert world.invocations == [] # no job + pending, thread = await _session_rows(project) + assert pending == {} and thread is None # the session store is not used + + # --- tracing: one Tool call span from the host, with the IS child -------- + finished = list(spans.get_finished_spans()) + [run] = [s for s in finished if s.name.startswith("Agent run - ")] + calls = [s for s in finished if s.name == f"Tool call - {IS_TOOL}"] + assert len(calls) == 1, [s.name for s in finished] # no duplicate Mcp item span + [call] = calls + assert call.parent is not None and call.parent.span_id == run.context.span_id + assert call.attributes["toolType"] == "Integration" + assert call.attributes["tool.server"] == "uipath_tools" + assert call.attributes["connector_key"] == "uipath-uipath-airdk" + [child] = [s for s in finished if s.name == "Web Search"] + assert child.attributes["span_type"] == "integrationTool" + assert child.parent is not None and child.parent.span_id == call.context.span_id + + from uipath.eval._helpers.evaluators_helpers import ( + extract_tool_calls, + extract_tool_calls_names, + extract_tool_calls_outputs, + ) + + ours: list[ReadableSpan] = [call] + assert extract_tool_calls_names(ours) == [IS_TOOL] + assert [c.args for c in extract_tool_calls(ours)] == [{"query": "uipath codex"}] + [out] = extract_tool_calls_outputs(ours) + assert json.loads(out.output) == RESULT + # Across the whole run, the IS call is the only tool call the evals see. + assert extract_tool_calls_names( + [s for s in finished if s.context.trace_id == run.context.trace_id] + ) == [IS_TOOL] + + +async def test_integration_error_reaches_the_model(project, upstream, world): + (project / "main.py").write_text(_main(durable=False)) + world.connections.fail = enriched(400, message="query is too long") + upstream.script = IntegrationScript() + + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + output = _final(result)[IS_CALL_ID] + assert "Integration service returned an error" in output, output + assert "query is too long" in output + assert len(world.connections.invocations) == 1 + + +@pytest.mark.parametrize("is_after", [False, True], ids=["is_before", "is_after"]) +async def test_integration_and_durable_calls_in_one_response( + project, upstream, world, is_after +): + """The IS call is answered while the durable call is held; the run + suspends on the job, and on resume both outputs are paired exactly once.""" + (project / "main.py").write_text(_main(durable=True)) + world.connections.responses["/v2/webSearch"] = RESULT + script = MixedScript(is_after=is_after) + upstream.script = script + cid = script.call_ids[0] + + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert list(result.output) == [cid] + assert len(world.connections.invocations) == 1 + [job] = world.invocations + world.finish(job.job_key, "Successful", {"sum": 3}) + + _new_robot(project) + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + final = _final(result) + assert json.loads(final[cid]) == {"sum": 3} + assert json.loads(final[IS_CALL_ID]) == RESULT + assert len(world.connections.invocations) == 1 # never re-run + + last = upstream.snapshot()[-1].body + is_outs = _outputs_for(last, IS_CALL_ID) + assert len(is_outs) == 1, summarize_input(last) + assert "Not executed" not in _text(is_outs[0]["output"]) + assert "aborted" not in _text(is_outs[0]["output"]) + assert len(_outputs_for(last, cid)) == 1, summarize_input(last) + + +async def test_integration_call_cut_off_by_the_settle_timeout(project, upstream, world): + """An IS call still running when the settle timeout ends is cancelled; the + model is told its outcome is unknown, not that it was not executed.""" + (project / "main.py").write_text(_main(durable=True, settle=2.0)) + world.connections.gate = asyncio.Event() # never released + script = MixedScript(is_after=True) + upstream.script = script + cid = script.call_ids[0] + + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert len(world.connections.invocations) == 1 + [job] = world.invocations + world.finish(job.job_key, "Successful", {"sum": 3}) + + world.connections.gate = None + _new_robot(project) + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + final = _final(result) + assert json.loads(final[cid]) == {"sum": 3} + assert final[IS_CALL_ID] == UNKNOWN_OUTCOME_OUTPUT + assert len(world.connections.invocations) == 1 diff --git a/packages/uipath-codex-sdk/tests/durable/test_ixp_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_ixp_e2e.py new file mode 100644 index 00000000..88660610 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_ixp_e2e.py @@ -0,0 +1,312 @@ +"""Offline end-to-end tests of IXP extraction and its Document Validation task. + +The real factory, runtime, bundled codex, gateway shim, tool host and trigger +handler (whose IXP readers run against a fake documents service), with a +mock model that extracts a document, then sends it to validation. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .._typing import sqlite_storage +from ..contract.mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + message_item, +) + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +DOC_ID = "abcdabcd-0000-0000-0000-000000000001" +DOC = {"ID": DOC_ID, "FullName": "invoice.pdf", "MimeType": "application/pdf"} +EXTRACT_ID = "call_ixpextract0000000000001" +VALIDATE_ID = "call_ixpvalidate000000000001" + +MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import ( + UiPathAttachment, + UiPathCodexAgent, + uipath_document_validation_tool, + uipath_ixp_extraction_tool, +) + + +class Input(BaseModel): + invoice: UiPathAttachment + + +extract = uipath_ixp_extraction_tool( + "Invoice Extraction", project_name="Invoices", version_tag="live" +) + +agent = UiPathCodexAgent( + model="mock-model", + input_schema=Input, + prompt="PLEASE_CALL_DURABLE: extract {invoice}, then have it validated.", + tools=[ + extract, + uipath_document_validation_tool( + "Validate Invoice", + extraction_tool=extract, + storage_bucket_name="invoices-bucket", + title="Check the invoice", + ), + ], + durable_settle_timeout=20.0, +) +""" + + +def _text(output: Any) -> Any: + if isinstance(output, list): + texts = [p.get("text", "") for p in output if isinstance(p, dict)] + return texts[-1] if texts else "" + return output + + +class Script: + """Extract the document; once its fields are back, validate; then answer.""" + + def __init__(self, validate_first: bool = False) -> None: + self.validate_first = validate_first + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + outputs = { + it["call_id"]: _text(it.get("output")) + for it in items + if it.get("type") == "function_call_output" + } + tools = advertised_tools(body) + extract, ns = find_tool(tools, "Invoice_Extraction") + validate, vns = find_tool(tools, "Validate_Invoice") + if self.validate_first and VALIDATE_ID not in seen and validate: + return [function_call_item(VALIDATE_ID, validate, {}, vns)] + if EXTRACT_ID not in seen and extract: + args = {"attachment": {"ID": DOC_ID}} + return [function_call_item(EXTRACT_ID, extract, args, ns)] + if ( + not self.validate_first + and EXTRACT_ID in outputs + and VALIDATE_ID not in seen + and validate + ): + return [function_call_item(VALIDATE_ID, validate, {}, vns)] + return [message_item("FINAL " + json.dumps(outputs, sort_keys=True), idx)] + + +@pytest.fixture +def project(project: Path, monkeypatch) -> Path: + (project / "main.py").write_text(MAIN) + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Coded") + return project + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +async def _run(project: Path, input, *, resume: bool): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _final_outputs(result) -> dict: + text = result.output["output"] + assert text.startswith("FINAL "), text + return json.loads(text[len("FINAL ") :]) + + +async def test_extract_then_validate(project, upstream, world, spans): + upstream.script = Script() + docs = world.documents + + # --- run: download + start the extraction, suspend ------------------ + result, triggers = await _run(project, {"invoice": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + [start] = docs.starts + assert (start["project_name"], start["tag"]) == ("Invoices", "live") + assert start["content"] == f"document {DOC_ID}" # the downloaded attachment + assert Path(start["file_path"]).name == "invoice.pdf" + assert not Path(start["file_path"]).exists() # the temporary copy is gone + assert result.output[EXTRACT_ID]["extraction_id"] == start["operation_id"] + [trig] = triggers + assert trig.trigger_type == UiPathResumeTriggerType.IXP_EXTRACTION + assert trig.trigger_name == UiPathResumeTriggerName.EXTRACTION + assert trig.item_key == start["operation_id"] + assert (trig.payload["project_id"], trig.payload["tag"]) == ( + docs.PROJECT_ID, + "live", + ) + + # --- still running: suspends again ---------------------------------- + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + + # --- extracted: the model gets the fields and validates ------------- + docs.finish_extraction(start["operation_id"]) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + [validation] = docs.validation_starts + assert validation["storage_bucket_name"] == "invoices-bucket" + assert ( + validation["action_folder"] == "Coded" + ) # the job's: DU finds the bucket there + assert validation["action_title"] == "Check the invoice" + # The full extraction response, kept from the first suspension. + assert validation["extraction_response"].extraction_result.document_id == ( + f"doc-{start['operation_id']}" + ) + [trig] = triggers + assert trig.trigger_type == UiPathResumeTriggerType.IXP_VS_ESCALATION + assert trig.item_key == validation["operation_id"] + + # --- validated: the model gets the corrected fields ----------------- + docs.complete_validation(validation["operation_id"], total="4250") + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + outputs = _final_outputs(result) + extracted = json.loads(outputs[EXTRACT_ID]) + assert extracted[0]["fieldValues"][0]["value"] == "4200" + validated = json.loads(outputs[VALIDATE_ID]) + assert validated["data"][0]["fieldValues"][0]["value"] == "4250" + + finished = spans.get_finished_spans() + [ext_call] = [s for s in finished if s.name == "Tool call - Invoice_Extraction"] + [ext] = [s for s in finished if s.name == "Invoice Extraction"] + assert ext.parent.span_id == ext_call.context.span_id + assert ext_call.attributes["toolType"] == "IxpExtraction" + assert ext.attributes["span_type"] == "ixpTool" + assert ext.attributes["projectName"] == "Invoices" + assert ext.attributes["versionTag"] == "live" + assert ext.attributes["extractionId"] == start["operation_id"] + assert "result" in ext.attributes + [val_call] = [s for s in finished if s.name == "Tool call - Validate_Invoice"] + [val] = [s for s in finished if s.name == "Validate Invoice"] + assert val.parent.span_id == val_call.context.span_id + assert val_call.attributes["toolType"] == "Escalation" + assert val.attributes["span_type"] == "vsEscalationTool" + assert val.attributes["ixpToolId"] == "Invoice Extraction" + assert val.attributes["storageBucketName"] == "invoices-bucket" + assert val.attributes["operationId"] == validation["operation_id"] + assert val.attributes["taskUrl"].endswith(validation["operation_id"]) + + +async def _to_validation(project, upstream, world) -> dict[str, Any]: + upstream.script = Script() + docs = world.documents + result, _ = await _run(project, {"invoice": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + docs.finish_extraction(docs.starts[0]["operation_id"]) + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + return docs.validation_starts[0] + + +async def test_document_marked_as_exception_ends_the_run(project, upstream, world): + validation = await _to_validation(project, upstream, world) + world.documents.complete_validation( + validation["operation_id"], rejection="Not an invoice" + ) + _new_robot(project) + with pytest.raises(Exception) as info: + await _run(project, None, resume=True) + text = str(getattr(info.value, "error_info", None) or info.value) + assert "TERMINATION_ESCALATION_REJECTED" in text + assert "Not an invoice" in text + + +async def test_deleted_validation_task_fails_the_run(project, upstream, world): + validation = await _to_validation(project, upstream, world) + world.documents.validations[validation["operation_id"]]["status"] = "Deleted" + _new_robot(project) + with pytest.raises(Exception) as info: + await _run(project, None, resume=True) + assert "deleted" in str(info.value).lower() + + +async def test_failed_extraction_fails_the_run(project, upstream, world): + upstream.script = Script() + result, _ = await _run(project, {"invoice": DOC}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + world.documents.finish_extraction( + world.documents.starts[0]["operation_id"], "Failed" + ) + _new_robot(project) + with pytest.raises(Exception) as info: + await _run(project, None, resume=True) + assert "failed" in str(info.value).lower() + assert world.documents.validation_starts == [] + + +async def test_start_error_fails_the_run(project, upstream, world): + world.documents.fail_start = ValueError("Project 'Invoices' not found.") + upstream.script = Script() + with pytest.raises(Exception) as info: + await _run(project, {"invoice": DOC}, resume=False) + error = info.value + assert "IXP_FAILED" in str(getattr(error, "error_info", None) or error) + assert "Project 'Invoices' not found." in str(error) + + +async def test_validation_before_extraction_is_a_tool_error(project, upstream, world): + upstream.script = Script(validate_first=True) + result, _ = await _run(project, {"invoice": DOC}, resume=False) + # The validation call was refused; the extraction then suspended the run. + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert world.documents.validation_starts == [] + assert len(world.documents.starts) == 1 diff --git a/packages/uipath-codex-sdk/tests/durable/test_mocking_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_mocking_e2e.py new file mode 100644 index 00000000..4847a366 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_mocking_e2e.py @@ -0,0 +1,536 @@ +"""Offline end-to-end tests of eval tool mocking with durable tools. + +The real factory, runtime, bundled codex, gateway shim and tool host, wrapped +in ``UiPathMockRuntime`` the way ``uipath eval`` wraps each case: a simulated +durable call is answered in the same turn (no job, task or extraction, no +suspension), the others suspend and resume as usual. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.eval._execution_context import ExecutionSpanCollector +from uipath.eval.mocks import UiPathMockRuntime +from uipath.eval.mocks._types import ( + LLMMockingStrategy, + MockingContext, + MockitoMockingStrategy, + ToolSimulation, +) +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .._typing import sqlite_storage +from ..contract.mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + message_item, +) +from .fakes import AddScript, EscalationScript +from .test_cli import _uipath +from .test_ixp_e2e import DOC, EXTRACT_ID, VALIDATE_ID +from .test_ixp_e2e import MAIN as IXP_MAIN +from .test_ixp_e2e import Script as IxpScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" + +MIXED_MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import ( + UiPathCodexAgent, + uipath_escalation_tool, + uipath_process_tool, +) + + +class AddIn(BaseModel): + a: int + b: int + + +class Review(BaseModel): + amount: float + reason: str + + +class Decision(BaseModel): + Comment: str + + +agent = UiPathCodexAgent( + model="mock-model", + prompt="PLEASE_CALL_DURABLE: add the numbers and ask a manager.", + tools=[ + uipath_process_tool( + "a_plus_b", + folder_path="Shared/Durable", + description="Adds a and b.", + input_schema=AddIn, + ), + uipath_escalation_tool( + "ExpenseApproval", + folder_path="Shared", + input_schema=Review, + output_schema=Decision, + outcome_mapping={"reject": "end"}, + ), + ], + durable_settle_timeout=20.0, +) +""" + +ESCALATION = "escalate_ExpenseApproval" + + +def mockito(*behaviors: dict[str, Any]) -> MockitoMockingStrategy: + return MockitoMockingStrategy.model_validate( + {"type": "mockito", "config": list(behaviors)} + ) + + +def returns(function: str, value: Any) -> dict[str, Any]: + return {"function": function, "then": [{"type": "return", "value": value}]} + + +async def _run(project: Path, input, strategy: Any, *, resume: bool = False): + """One execution of the case, as ``uipath eval`` runs it.""" + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + mocked = UiPathMockRuntime( + runtime, + MockingContext(strategy=strategy, name="case", inputs=input or {}), + span_collector=ExecutionSpanCollector(), + execution_id="e1", + ) + try: + result = await mocked.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _text(output: Any) -> str: + """A function_call_output as text (a live MCP result is content parts).""" + if isinstance(output, list): + texts = [p.get("text", "") for p in output if isinstance(p, dict)] + return texts[-1] if texts else "" + return output if isinstance(output, str) else json.dumps(output) + + +def _outputs(body: dict[str, Any], call_id: str) -> list[str]: + return [ + _text(it.get("output")) + for it in input_items(body) + if it.get("type") == "function_call_output" and it.get("call_id") == call_id + ] + + +def _first_with_output(mock, call_id: str): + for req in mock.snapshot(): + if _outputs(req.body, call_id): + return req + return None + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +# -------------------------------------------------------------------------- +# a./b. a mocked process tool never starts a job and never suspends +# -------------------------------------------------------------------------- +async def test_mockito_mocked_process_answers_in_the_same_turn( + project, upstream, world +): + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + result, triggers = await _run( + project, {}, mockito(returns("a_plus_b", {"sum": 42})) + ) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert world.invocations == [] and not triggers + req = _first_with_output(upstream, cid) + assert req is not None + assert _outputs(req.body, cid) == ['{"sum": 42}'] + assert result.output["output"].startswith("FINAL ") + + +async def test_llm_mocked_process_needs_no_network( + project, upstream, world, monkeypatch +): + import uipath.eval.mocks._llm_mocker as llm_mocker + + prompts: list[str] = [] + + async def generate(llm, messages, *, schema, **kw): + prompts.append(messages[0]["content"]) + return {"sum": 99} + + monkeypatch.setattr(llm_mocker, "generate_structured_output", generate) + monkeypatch.setattr( + llm_mocker, + "UiPath", + lambda: type("U", (), {"_config": None, "_execution_context": None})(), + ) + monkeypatch.setattr(llm_mocker, "UiPathLlmChatService", lambda *a, **k: object()) + script = AddScript(n_calls=1) + upstream.script = script + cid = script.call_ids[0] + strategy = LLMMockingStrategy( + prompt="Return a plausible sum.", + tools_to_simulate=[ToolSimulation(name="a_plus_b")], + ) + result, _ = await _run(project, {}, strategy) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert world.invocations == [] + [prompt] = prompts + assert '"kwargs": {"a": 17, "b": 25}' in prompt + req = _first_with_output(upstream, cid) + assert req is not None and _outputs(req.body, cid) == ['{"sum": 99}'] + + +# -------------------------------------------------------------------------- +# c. a mocked escalation next to a real process call +# -------------------------------------------------------------------------- +class BothScript(AddScript): + """The durable ``a_plus_b`` call and the escalation in one response.""" + + ESCALATION_ID = "call_escalation00000000000001" + + def __init__(self) -> None: + super().__init__(n_calls=1) + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]] | None: + items = input_items(body) + seen = {it.get("call_id") for it in items if it.get("type") == "function_call"} + outputs = { + it["call_id"]: _text(it.get("output")) + for it in items + if it.get("type") == "function_call_output" + } + if self.call_ids[0] in seen: + return [message_item("FINAL " + json.dumps(outputs, sort_keys=True), idx)] + tools = advertised_tools(body) + add, ns = find_tool(tools, "a_plus_b") + esc, ens = find_tool(tools, ESCALATION) + if not add or not esc: + return None + return [ + function_call_item(self.ESCALATION_ID, esc, EscalationScript.ARGS, ens), + function_call_item(self.call_ids[0], add, {"a": 1, "b": 2}, ns), + ] + + +async def test_mocked_escalation_with_a_real_process(project, upstream, world): + (project / "main.py").write_text(MIXED_MAIN) + script = BothScript() + upstream.script = script + cid, eid = script.call_ids[0], BothScript.ESCALATION_ID + strategy = mockito( + returns(ESCALATION.lower(), {"action": "approve", "data": {"Comment": "fine"}}) + ) + result, triggers = await _run(project, {}, strategy) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert list(result.output) == [cid] # only the process call is pending + assert [t.interrupt_id for t in triggers] == [cid] + assert world.tasks.created == [] # no Action Center task + [inv] = world.invocations + + world.finish(inv.job_key, "Successful", {"sum": 3}) + _new_robot(project) + result, triggers = await _run(project, None, strategy, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + final = upstream.snapshot()[-1].body + assert _outputs(final, cid) == [json.dumps({"sum": 3})] + [escalation] = _outputs(final, eid) # answered once, never re-mocked + assert json.loads(escalation) == { + "status": "completed", + "outcome": "approve", + "output": {"Comment": "fine"}, + "task_id": None, + "assigned_to": None, + } + assert world.tasks.created == [] + + +# -------------------------------------------------------------------------- +# d./e. IXP extraction and its Document Validation +# -------------------------------------------------------------------------- +def extraction(total: str = "999") -> dict[str, Any]: + from .fakes import FakeDocuments + + return { + "extractionResult": { + "DocumentId": "doc-simulated", + "ResultsVersion": 1, + "ResultsDocument": {"Fields": []}, + }, + "projectId": "simulated-project", + "projectType": "IXP", + "tag": "live", + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "dataProjection": FakeDocuments.projection(total), + } + + +@pytest.fixture +def ixp_project(project: Path, monkeypatch) -> Path: + (project / "main.py").write_text(IXP_MAIN) + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Coded") + return project + + +async def test_mocked_extraction_feeds_a_real_validation(ixp_project, upstream, world): + from .fakes import FakeDocuments + + upstream.script = IxpScript() + docs = world.documents + strategy = mockito(returns("Invoice Extraction", extraction())) + result, triggers = await _run(ixp_project, {"invoice": DOC}, strategy) + # The extraction was simulated; the validation task is real. + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert docs.starts == [] and world.attachments.downloads == [] + assert list(result.output) == [VALIDATE_ID] + [validation] = docs.validation_starts + sent = validation["extraction_response"] + assert sent.project_id == "simulated-project" + assert sent.extraction_result.document_id == "doc-simulated" + req = _first_with_output(upstream, EXTRACT_ID) + assert req is not None + assert json.loads(_outputs(req.body, EXTRACT_ID)[0]) == FakeDocuments.projection( + "999" + ) + + +async def test_mocked_extraction_and_validation_touch_no_documents( + ixp_project, upstream, world +): + from .fakes import FakeDocuments + + upstream.script = IxpScript() + docs = world.documents + validated = {"data": FakeDocuments.projection("1000")} + strategy = mockito( + returns("Invoice Extraction", extraction()), + returns("Validate Invoice", validated), + ) + result, triggers = await _run(ixp_project, {"invoice": DOC}, strategy) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + assert docs.starts == [] and docs.validation_starts == [] + final = upstream.snapshot()[-1].body + assert json.loads(_outputs(final, VALIDATE_ID)[0]) == validated + + +# -------------------------------------------------------------------------- +# f. a simulated escalation outcome mapped to "end" ends the run +# -------------------------------------------------------------------------- +async def test_mocked_escalation_outcome_mapped_to_end(project, upstream, world): + from uipath_codex_sdk.runtime.errors import UiPathRunTerminatedError + + (project / "main.py").write_text(MIXED_MAIN) + upstream.script = EscalationScript() + strategy = mockito( + returns(ESCALATION.lower(), {"action": "reject", "data": {"Comment": "no"}}) + ) + with pytest.raises(Exception) as info: + await _run(project, {}, strategy) + error = info.value + assert isinstance(error, UiPathRunTerminatedError) + assert "TERMINATION_ESCALATION_REJECTED" in str(error.error_info.code) + assert world.tasks.created == [] and world.invocations == [] + + +# -------------------------------------------------------------------------- +# g. tracing: low-code's "Simulated result" child under the Tool call +# -------------------------------------------------------------------------- +async def test_mocked_process_trace(project, upstream, world, spans): + script = AddScript(n_calls=1) + upstream.script = script + result, _ = await _run(project, {}, mockito(returns("a_plus_b", {"sum": 42}))) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + finished = spans.get_finished_spans() + [call] = [s for s in finished if s.name == "Tool call - a_plus_b"] + [child] = [s for s in finished if s.name == "Simulated result: a_plus_b"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert child.attributes["span_type"] == "processTool" + assert child.attributes["__uipath_response_mocked"] is True + assert json.loads(call.attributes["output.value"]) == {"content": '{"sum": 42}'} + [run] = [s for s in finished if s.name.startswith("Agent run")] + assert call.parent is not None and call.parent.span_id == run.context.span_id + + +# -------------------------------------------------------------------------- +# h. the real ``uipath eval`` CLI, offline +# -------------------------------------------------------------------------- +CONTAINS_EVALUATOR = { + "version": "1.0", + "id": "evaluator-contains", + "description": "The answer contains the simulated sum.", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "Contains", + "case_sensitive": False, + "targetOutputKey": "*", + }, +} + +MOCKITO_SET = { + "version": "1.0", + "id": "mocked-set", + "name": "Mocked durable tool", + "evaluatorRefs": ["evaluator-contains"], + "evaluations": [ + { + "id": "mocked-sum", + "name": "Mocked sum", + "inputs": {}, + "evaluationCriterias": {"evaluator-contains": {"searchText": "4242"}}, + "mockingStrategy": { + "type": "mockito", + "config": [ + { + "function": "a_plus_b", + "arguments": {"kwargs": {"a": 17, "b": 25}}, + "then": [{"type": "return", "value": {"sum": 4242}}], + } + ], + }, + } + ], +} + +STAMP = "2026-01-01T00:00:00.000Z" +LEGACY_SET = { + "id": "legacy-set", + "fileName": "legacy.json", + "name": "Legacy simulated tools", + "evaluatorRefs": [], + "createdAt": STAMP, + "updatedAt": STAMP, + "evaluations": [ + { + "id": "legacy-sum", + "name": "Legacy sum", + "inputs": {}, + "expectedOutput": {}, + "evalSetId": "legacy-set", + "createdAt": STAMP, + "updatedAt": STAMP, + "simulateTools": True, + "simulationInstructions": "Return the sum 7777.", + "toolsToSimulate": [{"name": "a_plus_b"}], + } + ], +} + + +@pytest.fixture +def eval_project(cli_env): + proj, server = cli_env + sets = proj / "evaluations" / "eval-sets" + evaluators = proj / "evaluations" / "evaluators" + sets.mkdir(parents=True) + evaluators.mkdir(parents=True) + (evaluators / "contains.json").write_text(json.dumps(CONTAINS_EVALUATOR)) + (sets / "mocked.json").write_text(json.dumps(MOCKITO_SET)) + (sets / "legacy.json").write_text(json.dumps(LEGACY_SET)) + return proj, server + + +def test_uipath_eval_with_a_mockito_strategy(eval_project, world): + proj, server = eval_project + script = AddScript(n_calls=1) + server.script = script + res = _uipath( + "eval", + "agent", + "evaluations/eval-sets/mocked.json", + "--no-report", + "--output-file", + "eval-out.json", + ) + assert res.exit_code == 0, res.output + assert world.invocations == [] # no Orchestrator job + out = json.loads((proj / "eval-out.json").read_text()) + [case] = out["evaluationSetResults"] + [score] = case["evaluationRunResults"] + assert score["result"]["score"] == 1.0, out + cid = script.call_ids[0] + assert _outputs(server.snapshot()[-1].body, cid) == ['{"sum": 4242}'] + + +def test_uipath_eval_with_a_legacy_simulated_tool(eval_project, world, monkeypatch): + import uipath.eval.mocks._llm_mocker as llm_mocker + + seen: list[dict[str, Any]] = [] + + async def generate(llm, messages, *, schema, **kw): + seen.append(kw.get("completion_kwargs") or {}) + return {"sum": 7777} + + monkeypatch.setattr(llm_mocker, "generate_structured_output", generate) + monkeypatch.setattr( + llm_mocker, + "UiPath", + lambda: type("U", (), {"_config": None, "_execution_context": None})(), + ) + monkeypatch.setattr(llm_mocker, "UiPathLlmChatService", lambda *a, **k: object()) + proj, server = eval_project + script = AddScript(n_calls=1) + server.script = script + res = _uipath( + "eval", + "agent", + "evaluations/eval-sets/legacy.json", + "--no-report", + "--output-file", + "legacy-out.json", + ) + assert res.exit_code == 0, res.output + assert world.invocations == [] + # The LLM mocker runs on the agent's model (``metadata.settings.model``). + assert [k.get("model") for k in seen] == ["mock-model"] + cid = script.call_ids[0] + assert _outputs(server.snapshot()[-1].body, cid) == ['{"sum": 7777}'] diff --git a/packages/uipath-codex-sdk/tests/durable/test_process_resource_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_process_resource_e2e.py new file mode 100644 index 00000000..764a575a --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_process_resource_e2e.py @@ -0,0 +1,301 @@ +"""Offline end-to-end tests of a process tool built from an agent.json resource. + +Same set-up as ``test_e2e``: the real factory, runtime, bundled codex, gateway +shim, tool host and ``UiPathResumeTriggerHandler``, with a mock model and fake +Orchestrator jobs. The tool comes from ``process_tool_from_resource`` with one +``argumentProperties`` entry of each supported variant: the model fills only +``amount``, the job gets the merged input. The mocked variant runs the same +case under ``UiPathMockRuntime``, as ``uipath eval`` does. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType +from uipath.eval._execution_context import ExecutionSpanCollector +from uipath.eval.mocks import UiPathMockRuntime +from uipath.eval.mocks._types import MockingContext, MockitoMockingStrategy +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .._typing import sqlite_storage +from ..contract.mock_responses import input_items, summarize_input +from .fakes import IntegrationScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +TOOL = "Create_Invoice" +CALL_ID = "call_createinvoice0000000001" +AGENT_INPUT = {"customer": {"id": "C1", "name": "Ada"}, "region": "EU"} +#: What the job gets: the model's ``amount`` plus every pre-filled field. +MERGED = { + "amount": 10, + "currency": "EUR", + "apiKey": "s3cr3t", + "customerId": "C1", + "memo": "Invoice for Ada", + "tags": ["agent", "EU"], +} + +MAIN = """ +import json + +from pydantic import BaseModel + +from uipath_codex_sdk import UiPathCodexAgent, process_tool_from_resource + + +class Customer(BaseModel): + id: str + name: str + + +class Input(BaseModel): + customer: Customer + region: str + + +RESOURCE = json.loads(''' +{ + "$resourceType": "tool", + "type": "process", + "name": "Create Invoice", + "description": "Creates an invoice in the ERP.", + "isEnabled": true, + "inputSchema": { + "type": "object", + "properties": { + "customerId": {"type": "string"}, + "apiKey": {"type": "string"}, + "memo": {"type": "string"}, + "currency": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "amount": {"type": "number"} + }, + "required": ["customerId", "amount", "currency", "apiKey"] + }, + "outputSchema": {"type": "object", "properties": {"invoiceId": {"type": "string"}}}, + "properties": {"processName": "CreateInvoice", "folderPath": "Shared/Finance"}, + "settings": {"timeout": 600}, + "argumentProperties": { + "$['currency']": {"variant": "static", "value": "EUR", "isSensitive": false}, + "$['apiKey']": {"variant": "static", "value": "s3cr3t", "isSensitive": true}, + "$['customerId']": {"variant": "argument", "argumentPath": "$.customer.id", + "isSensitive": false}, + "$['memo']": {"variant": "textBuilder", "isSensitive": false, "tokens": [ + {"type": "simpleText", "rawString": "Invoice for "}, + {"type": "variable", "rawString": "input.customer.name"}]}, + "$['tags']": {"variant": "arrayBuilder"}, + "$['tags'][0]": {"variant": "static", "value": "agent", "isSensitive": false}, + "$['tags'][1]": {"variant": "argument", "argumentPath": "$.region", + "isSensitive": false} + } +} +''') + +agent = UiPathCodexAgent( + model="mock-model", + prompt="Create the invoice for the customer.", + input_schema=Input, + tools=[process_tool_from_resource(RESOURCE)], + durable_settle_timeout=20.0, +) +""" + + +class InvoiceScript(IntegrationScript): + """Mock model: call ``Create_Invoice`` with only the amount, then answer + with every tool output; records the advertised tool spec.""" + + def __init__(self) -> None: + super().__init__(tool=TOOL, args={"amount": 10}) + self.call_id = CALL_ID + + +@pytest.fixture +def project(project: Path, monkeypatch) -> Path: + (project / "main.py").write_text(MAIN) + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Coded/Run") + return project + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +def _factory(project: Path, *, resume: bool) -> UiPathCodexRuntimeFactory: + return UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + ) + + +async def _run(project: Path, input, *, resume: bool): + factory = _factory(project, resume=resume) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + storage = await sqlite_storage(factory) + return result, await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _tool_spec(script: InvoiceScript) -> dict[str, Any]: + """The advertised ``Create_Invoice`` spec (inside its MCP namespace).""" + + def walk(node: Any) -> dict[str, Any] | None: + if isinstance(node, dict): + if node.get("name") == TOOL and "parameters" in node: + return node + for value in node.values(): + found = walk(value) + if found is not None: + return found + elif isinstance(node, list): + for value in node: + found = walk(value) + if found is not None: + return found + return None + + spec = walk(script.tool_specs) + assert spec is not None, script.tool_specs + return spec + + +def _assert_paired(body: dict[str, Any], expected_output: str) -> None: + items = input_items(body) + summary = summarize_input(body) + calls = [ + i + for i, it in enumerate(items) + if it.get("type") == "function_call" and it.get("call_id") == CALL_ID + ] + outs = [ + (i, it) + for i, it in enumerate(items) + if it.get("type") == "function_call_output" and it.get("call_id") == CALL_ID + ] + assert len(calls) == 1 and len(outs) == 1, summary + (oi, out) = outs[0] + output = out["output"] + if isinstance(output, list): # a live MCP result: content parts + output = [p.get("text") for p in output if isinstance(p, dict)][-1] + assert output == expected_output, summary + assert oi > calls[0], summary + assert "turn_aborted" not in json.dumps(items), summary + + +async def test_resource_tool_suspends_with_the_merged_input_then_resumes( + project, upstream, world, spans +): + script = InvoiceScript() + upstream.script = script + + # --- run: the model fills only the amount; the job gets everything --- + result, triggers = await _run(project, AGENT_INPUT, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert script.advertised == (TOOL, "mcp__uipath_tools") + parameters = _tool_spec(script)["parameters"] + assert set(parameters["properties"]) == {"customerId", "amount"} + assert parameters["required"] == ["amount"] + [inv] = world.invocations + assert inv.name == "CreateInvoice" + assert inv.input_arguments == MERGED + assert inv.folder_path == "Coded/Run" # the run's folder, not folderPath + assert result.output[CALL_ID]["arguments"] == {"amount": 10} + [trig] = triggers + assert trig.trigger_type == UiPathResumeTriggerType.JOB + assert trig.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert trig.item_key == inv.job_key + # the Tool call span stays open across the suspension + assert not [s for s in spans.get_finished_spans() if s.name.startswith("Tool")] + + # --- the job finished: the run resumes and the output is paired ------ + world.finish(inv.job_key, "Successful", {"invoiceId": "INV-7"}) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + assert len(world.invocations) == 1 # never started again + expected = json.dumps({"invoiceId": "INV-7"}) + _assert_paired(upstream.snapshot()[-1].body, expected) + [call] = [s for s in spans.get_finished_spans() if s.name == f"Tool call - {TOOL}"] + assert call.attributes["toolType"] == "Process" + assert json.loads(call.attributes["input"]) == MERGED # low-code's tool call + + +def mockito(*behaviors: dict[str, Any]) -> MockitoMockingStrategy: + return MockitoMockingStrategy.model_validate( + {"type": "mockito", "config": list(behaviors)} + ) + + +async def test_mocked_resource_tool_gets_the_merged_input( + project, upstream, world, spans +): + script = InvoiceScript() + upstream.script = script + # The behavior matches only on the merged input, as in LangChain. + strategy = mockito( + { + "function": "Create Invoice", + "arguments": {"kwargs": MERGED}, + "then": [{"type": "return", "value": {"invoiceId": "SIM-1"}}], + } + ) + factory = _factory(project, resume=False) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + mocked = UiPathMockRuntime( + runtime, + MockingContext(strategy=strategy, name="case", inputs=AGENT_INPUT), + span_collector=ExecutionSpanCollector(), + execution_id="e1", + ) + try: + result = await mocked.execute(AGENT_INPUT, UiPathExecuteOptions(resume=False)) + storage = await sqlite_storage(factory) + triggers = await storage.get_triggers(RUNTIME_ID) + finally: + await runtime.dispose() + await factory.dispose() + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert world.invocations == [] and not triggers # no job, no suspension + _assert_paired(upstream.snapshot()[-1].body, json.dumps({"invoiceId": "SIM-1"})) + finished = {s.name: s for s in spans.get_finished_spans()} + child = finished["Simulated result: CreateInvoice"] + assert child.attributes["span_type"] == "processTool" + assert json.loads(child.attributes["arguments"]) == MERGED + assert json.loads(finished[f"Tool call - {TOOL}"].attributes["input"]) == MERGED diff --git a/packages/uipath-codex-sdk/tests/durable/test_resume_input.py b/packages/uipath-codex-sdk/tests/durable/test_resume_input.py new file mode 100644 index 00000000..67f115b2 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_resume_input.py @@ -0,0 +1,162 @@ +"""``--resume`` with an input that answers no pending call. + +``uipath run agent --file input.json --resume`` passes the agent input again. +``UiPathResumableRuntime`` takes any non-``None`` input as the resume input and +deletes the (single) trigger, so the runtime must read the pending jobs itself +instead of using the input as a job result or waiting another resume cycle. +""" + +from __future__ import annotations + +import json +import logging +import shutil +from pathlib import Path + +import pytest +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, +) + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime + +from .._typing import sqlite_storage +from ..contract.mock_responses import input_items, summarize_input +from .fakes import AddScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +AGENT_INPUT = {"topic": "sums"} + + +async def _run(project: Path, input, *, resume: bool): + context = UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=resume + ) + factory = UiPathCodexRuntimeFactory(context) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + result = await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + triggers = await (await sqlite_storage(factory)).get_triggers(RUNTIME_ID) + return result, triggers + finally: + await runtime.dispose() + await factory.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _outputs_by_a(upstream, call_ids: list[str]) -> dict[int, str]: + """``{argument a: injected output}`` of the request answering all calls.""" + for req in upstream.snapshot(): + items = input_items(req.body) + outs = { + it["call_id"]: it["output"] + for it in items + if it.get("type") == "function_call_output" + } + if set(call_ids) <= set(outs): + assert "turn_aborted" not in json.dumps(items), summarize_input(req.body) + return { + json.loads(it["arguments"])["a"]: outs[it["call_id"]] + for it in items + if it.get("type") == "function_call" + } + raise AssertionError([summarize_input(r.body) for r in upstream.snapshot()]) + + +@pytest.fixture +def prepares(monkeypatch) -> list[object]: + """Inputs ``_prepare_resume`` was called with (one per resume cycle).""" + seen: list[object] = [] + original = UiPathCodexRuntime._prepare_resume + + async def counting(self, input): + seen.append(input) + return await original(self, input) + + monkeypatch.setattr(UiPathCodexRuntime, "_prepare_resume", counting) + return seen + + +async def _suspend(project: Path, upstream, n_calls: int) -> AddScript: + script = AddScript(n_calls=n_calls) + upstream.script = script + result, triggers = await _run(project, AGENT_INPUT, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert triggers is not None and len(triggers) == n_calls + return script + + +@pytest.mark.parametrize("n_calls", [1, 2], ids=["one-call", "two-calls"]) +async def test_resume_with_agent_input_reads_the_jobs( + project, upstream, world, prepares, caplog, n_calls +): + script = await _suspend(project, upstream, n_calls) + for i, inv in enumerate(world.invocations): + world.finish(inv.job_key, "Successful", {"sum": 42 + i}) + + _new_robot(project) + caplog.set_level(logging.INFO, logger="uipath_codex_sdk") + result, triggers = await _run(project, dict(AGENT_INPUT), resume=True) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert not triggers + assert prepares == [AGENT_INPUT] # one resume cycle, no re-suspension + assert "still waiting" not in caplog.text + assert _outputs_by_a(upstream, script.call_ids) == { + 17 + i: json.dumps({"sum": 42 + i}) for i in range(n_calls) + } + assert len(world.invocations) == n_calls + + +async def test_resume_with_agent_input_while_job_runs( + project, upstream, world, prepares +): + """The input is not taken as the job result; the run keeps waiting on a + fresh trigger for the job (the wrapper deleted the old one).""" + script = await _suspend(project, upstream, 1) + _new_robot(project) + result, triggers = await _run(project, dict(AGENT_INPUT), resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert triggers is not None + assert [t.interrupt_id for t in triggers] == script.call_ids + assert triggers[0].item_key == world.invocations[0].job_key + assert prepares == [AGENT_INPUT] + + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + _new_robot(project) + result, triggers = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert _outputs_by_a(upstream, script.call_ids) == {17: json.dumps({"sum": 42})} + + +async def test_partial_finish_with_agent_input_drops_used_trigger( + project, upstream, world, prepares +): + """Two calls, one job done: the finished call's stored trigger is removed, + so a later resume cannot hand its job to the call still waiting.""" + script = await _suspend(project, upstream, 2) + by_a = {inv.input_arguments["a"]: inv for inv in world.invocations} + world.finish(by_a[17].job_key, "Successful", {"sum": 42}) + + _new_robot(project) + result, triggers = await _run(project, dict(AGENT_INPUT), resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert triggers is not None and len(triggers) == 1 + assert triggers[0].item_key == by_a[18].job_key + + world.finish(by_a[18].job_key, "Successful", {"sum": 43}) + _new_robot(project) + result, _ = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + assert _outputs_by_a(upstream, script.call_ids) == { + 17: json.dumps({"sum": 42}), + 18: json.dumps({"sum": 43}), + } diff --git a/packages/uipath-codex-sdk/tests/durable/test_tracing.py b/packages/uipath-codex-sdk/tests/durable/test_tracing.py new file mode 100644 index 00000000..cbe687ea --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_tracing.py @@ -0,0 +1,587 @@ +"""Offline end-to-end tests of the job's trace spans. + +Same set-up as ``test_e2e``: the real factory, runtime, bundled codex, gateway +shim and tool host, with a mock model and fake Orchestrator jobs. +""" + +from __future__ import annotations + +import json +import shutil +import threading +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from uipath.core.tracing.processors import UiPathExecutionBatchTraceProcessor +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathRuntimeContext, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.tracing import JsonLinesFileExporter, LiveTrackingSpanProcessor + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.session_store import CodexSessionStore +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + +from .._typing import as_result, attrs, end_ns, start_ns +from .fakes import AddScript, SiblingScript + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" + +PLAIN_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent + +agent = UiPathCodexAgent(model="mock-model", prompt="Say hi to {name}.") +""" + + +@pytest.fixture +def spans(): + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + assert isinstance(trace.get_tracer_provider(), TracerProvider) + exporter = InMemorySpanExporter() + processor = SimpleSpanProcessor(exporter) + trace.get_tracer_provider().add_span_processor(processor) # type: ignore[attr-defined] + yield exporter + exporter.shutdown() + + +class RecordingLlmOps: + """Stands in for ``LlmOpsHttpExporter``: records each upsert as sent.""" + + def __init__(self) -> None: + self.upserts: list[dict[str, Any]] = [] + self.enabled = True + self._lock = threading.Lock() + + def upsert_span(self, span, status_override=None): + if not self.enabled: + return None + with self._lock: + self.upserts.append( + { + "name": span.name, + "span_id": span.get_span_context().span_id, + "status_override": status_override, + "end_time": span.end_time, + "attributes": dict(span.attributes or {}), + } + ) + return None + + def shutdown(self) -> None: + pass + + def of(self, span_id: int) -> list[dict[str, Any]]: + with self._lock: + return [u for u in self.upserts if u["span_id"] == span_id] + + +@pytest.fixture +def llmops(spans): + """A live-tracking processor (what ``uipath run`` adds for a job).""" + exporter = RecordingLlmOps() + # One worker: upserts land in order, and ``drain`` waits for all of them. + processor = LiveTrackingSpanProcessor(exporter, max_workers=1) # type: ignore[arg-type] + trace.get_tracer_provider().add_span_processor(processor) # type: ignore[attr-defined] + + def drain() -> RecordingLlmOps: + processor.executor.submit(lambda: None).result() + return exporter + + yield drain + exporter.enabled = False # the provider keeps its processors + + +@pytest.fixture +def trace_file(tmp_path): + """What ``uipath run --trace-file`` adds: JSON lines behind a batch processor.""" + path = tmp_path / "traces.jsonl" + processor = UiPathExecutionBatchTraceProcessor(JsonLinesFileExporter(str(path))) + trace.get_tracer_provider().add_span_processor(processor) # type: ignore[attr-defined] + + def read() -> list[dict[str, Any]]: + processor.force_flush() + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + yield read + processor.shutdown() + + +async def _run(project: Path, input, *, resume: bool, **context): + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), + state_file="state.db", + resume=resume, + **context, + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + return await runtime.execute(input, UiPathExecuteOptions(resume=resume)) + finally: + await runtime.dispose() + await factory.dispose() + + +async def _saved_trace(project: Path) -> dict[str, Any] | None: + storage = SqliteResumableStorage(str(project / "__uipath" / "state.db")) + try: + return await CodexSessionStore(storage, RUNTIME_ID).get_trace() + finally: + await storage.dispose() + + +def _new_robot(project: Path) -> None: + shutil.rmtree(project / "__uipath" / "codex_home", ignore_errors=True) + + +def _by_name(spans: list[ReadableSpan], prefix: str) -> list[ReadableSpan]: + return [s for s in spans if s.name.startswith(prefix)] + + +def _parent_id(span: ReadableSpan) -> int | None: + return span.parent.span_id if span.parent is not None else None + + +async def test_one_agent_run_across_suspend_and_resumes( + project, upstream, world, spans, llmops, trace_file, monkeypatch +): + """suspend → resume (job still running: suspends again) → resume (done): + one Agent run, opened by the first execution and completed by the last.""" + monkeypatch.setenv("UIPATH_TRACE_ID", "0af7651916cd43dd8448eb211c80319c") + upstream.script = AddScript(n_calls=1) + + # --- first execution: suspends on the job --------------------------- + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + first = spans.get_finished_spans() + # The Agent run and the durable Tool call stay open across the suspension. + assert _by_name(first, "Agent run - ") == [] + assert _by_name(first, "Tool call - ") == [] + saved = await _saved_trace(project) + assert saved is not None + root_id = int(saved["span_id"], 16) + trace_id = int(saved["trace_id"], 16) + assert saved["name"] == "Agent run - agent" + assert saved["parent_span_id"] is None + assert saved["attributes"]["status"] == "suspended" + assert "a_plus_b" in saved["attributes"]["userPrompt"] + + llm_calls = _by_name(first, "LLM call") + assert llm_calls and all(_parent_id(s) == root_id for s in llm_calls) + assert all(attrs(s)["span_type"] == "llmCall" for s in llm_calls) + # The call that asked for the tool records it, as the gateway returned it. + assert any("a_plus_b" in attrs(s).get("toolCalls", "") for s in llm_calls) + # The LLM Gateway got the LLM call span as the trace parent. + traceparents = [ + r.headers.get("x-uipath-traceparent-id") for r in upstream.snapshot() + ] + expected = { + f"00-0af7651916cd43dd8448eb211c80319c-{s.context.span_id:016x}-01" + for s in llm_calls + } + assert traceparents and set(traceparents) <= expected + + # LLMOps saw both open spans as still running: never as finished. + [inv] = world.invocations + tool_id = int(inv.parent_span_id, 16) + live = llmops() + run_upserts = live.of(root_id) + assert run_upserts and all(u["end_time"] is None for u in run_upserts) + assert run_upserts[-1]["status_override"] == "Unset" + assert run_upserts[-1]["attributes"]["status"] == "suspended" + tool_upserts = live.of(tool_id) + assert tool_upserts and all(u["end_time"] is None for u in tool_upserts) + assert tool_upserts[-1]["status_override"] == "Unset" + started = json.loads(tool_upserts[-1]["attributes"]["output"]) + assert started["status"] == "started" and started["job_key"] == inv.job_key + + # --- resume while the job runs: suspends again under the same run --- + _new_robot(project) + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + second = spans.get_finished_spans() + assert _by_name(second, "Agent run - ") == [] + assert _by_name(second, "Tool call - ") == [] + # As for low-code agents: no group span. This execution only re-read the + # (fake, untraced) trigger and suspended again: it adds no span at all. + assert second[len(first) :] == () + assert await _saved_trace(project) == saved + assert all(u["end_time"] is None for u in llmops().of(root_id)) + + # --- resume after the job finished: the run completes --------------- + world.finish(inv.job_key, "Successful", {"sum": 42}) + _new_robot(project) + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + every = spans.get_finished_spans() + third = every[len(second) :] + # The resumed execution's spans sit directly under the restored Agent run. + assert _by_name(third, "Resume") == [] + resumed_llm = _by_name(third, "LLM call") + assert resumed_llm + assert all(_parent_id(s) == root_id for s in resumed_llm) + assert all(s.context.trace_id == trace_id for s in third) + + # The durable Tool call: completed once, the span the child job hangs under. + [tool] = _by_name(every, "Tool call - a_plus_b") + assert tool.context.span_id == tool_id + assert _parent_id(tool) == root_id + assert json.loads(attrs(tool)["input"]) == {"a": 17, "b": 25} + assert json.loads(attrs(tool)["output"]) == {"sum": 42} + assert tool.status.status_code.name == "OK" + # Completed when its result was handed back, before the model saw it. + assert start_ns(tool) < end_ns(tool) <= min(start_ns(s) for s in resumed_llm) + + # One Agent run for the whole job: original id and start, final status. + [run] = _by_name(every, "Agent run - ") + assert run.context.span_id == root_id + assert run.context.trace_id == trace_id + assert run.parent is None + assert run.start_time == saved["start_time_ns"] + assert run.start_time < min(s.start_time for s in every if s is not run) + assert run.end_time >= max(s.end_time for s in every if s is not run) + assert attrs(run)["status"] == "successful" + assert attrs(run)["resumed"] is False + assert attrs(run)["model"] == "mock-model" + assert json.loads(attrs(run)["output"]) == result.output + assert run.status.status_code.name == "OK" + [output] = _by_name(every, "Agent output") + assert _parent_id(output) == root_id + assert json.loads(attrs(output)["output"]) == result.output + assert all(s.context.trace_id == trace_id for s in every) + assert await _saved_trace(project) is None + + # The trace file holds the whole job as one tree. + lines = trace_file() + assert len(lines) == len(every) + [file_run] = [ln for ln in lines if ln["name"] == "Agent run - agent"] + assert file_run["context"]["span_id"] == f"0x{root_id:016x}" + assert file_run["parent_id"] is None + [file_tool] = [ln for ln in lines if ln["name"] == "Tool call - a_plus_b"] + assert file_tool["context"]["span_id"] == f"0x{tool_id:016x}" + ids = {ln["context"]["span_id"] for ln in lines} + assert all(ln["parent_id"] in ids for ln in lines if ln is not file_run) + + # LLMOps got the completions with the original ids. + live = llmops() + assert live.of(root_id)[-1]["end_time"] == run.end_time + assert live.of(root_id)[-1]["status_override"] is None + assert live.of(tool_id)[-1]["end_time"] == tool.end_time + + +async def test_streamed_resume_completes_the_agent_run(project, upstream, world, spans): + """``uipath run`` without a job streams: same single Agent run.""" + upstream.script = AddScript(n_calls=1) + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + saved = await _saved_trace(project) + assert saved is not None + [inv] = world.invocations + world.finish(inv.job_key, "Successful", {"sum": 42}) + _new_robot(project) + + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db", resume=True + ) + ) + runtime = await factory.new_runtime("agent", RUNTIME_ID) + try: + events = [ + e async for e in runtime.stream(None, UiPathStreamOptions(resume=True)) + ] + finally: + await runtime.dispose() + await factory.dispose() + assert as_result(events[-1]).status == UiPathRuntimeStatus.SUCCESSFUL + finished = spans.get_finished_spans() + [run] = _by_name(finished, "Agent run - ") + assert run.context.span_id == int(saved["span_id"], 16) + assert attrs(run)["status"] == "successful" + assert _by_name(finished, "Resume") == [] + llm_calls = _by_name(finished, "LLM call") + assert llm_calls + assert all(_parent_id(s) == run.context.span_id for s in llm_calls) + [tool] = _by_name(finished, "Tool call - a_plus_b") + assert tool.context.span_id == int(inv.parent_span_id, 16) + assert await _saved_trace(project) is None + + +async def test_faulted_resume_completes_the_run_and_open_calls_with_error( + project, upstream, world, spans, monkeypatch +): + """The resume faults after the first job's result was read, while the + second job still runs: the Agent run and the open call complete with ERROR.""" + upstream.script = AddScript(n_calls=2) + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + saved = await _saved_trace(project) + assert saved is not None + first, second = world.invocations + world.finish(first.job_key, "Successful", {"sum": 42}) + + async def broken(self, completed): + raise RuntimeError("database is locked") + + monkeypatch.setattr(CodexSessionStore, "set_completed", broken) + _new_robot(project) + with pytest.raises(RuntimeError, match="database is locked"): + await _run(project, None, resume=True) + + finished = spans.get_finished_spans() + [run] = _by_name(finished, "Agent run - ") + assert run.context.span_id == int(saved["span_id"], 16) + assert run.start_time == saved["start_time_ns"] + assert attrs(run)["status"] == "faulted" + assert run.status.status_code.name == "ERROR" + assert "database is locked" in attrs(run)["error"] + assert "output" not in attrs(run) # not the suspension's summary + assert _by_name(finished, "Resume") == [] + # Each durable Tool call completes exactly once, with its own id. + tools = _by_name(finished, "Tool call - a_plus_b") + assert sorted(t.context.span_id for t in tools) == sorted( + int(i.parent_span_id, 16) for i in world.invocations + ) + used = next(t for t in tools if t.context.span_id == int(first.parent_span_id, 16)) + assert json.loads(attrs(used)["output"]) == {"sum": 42} + unused = next(t for t in tools if t is not used) + assert unused.status.status_code.name == "ERROR" + assert "before the job result was used" in attrs(unused)["error"] + assert await _saved_trace(project) is None + + +async def test_fresh_run_forgets_a_saved_agent_run(project, upstream, world, spans): + """A non-resume run on a kept state file starts its own Agent run.""" + upstream.script = AddScript(n_calls=1) + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + saved = await _saved_trace(project) + assert saved is not None + + (project / "main.py").write_text(PLAIN_MAIN) + result = await _run(project, {"name": "Ada"}, resume=False, keep_state_file=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + [run] = _by_name(spans.get_finished_spans(), "Agent run - ") + assert run.context.span_id != int(saved["span_id"], 16) + assert run.parent is None + assert attrs(run)["resumed"] is False + assert await _saved_trace(project) is None + + +async def test_agent_without_durable_tools_is_one_agent_run( + project, upstream, world, spans +): + (project / "main.py").write_text(PLAIN_MAIN) + upstream.script = AddScript() + result = await _run(project, {"name": "Ada"}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + finished = spans.get_finished_spans() + [run] = _by_name(finished, "Agent run - ") + assert run.parent is None + assert attrs(run)["status"] == "successful" + assert run.status.status_code.name == "OK" + assert _by_name(finished, "Resume") == [] + [output] = _by_name(finished, "Agent output") + assert _parent_id(output) == run.context.span_id + assert all( + _parent_id(s) == run.context.span_id for s in _by_name(finished, "LLM call") + ) + assert await _saved_trace(project) is None + + +async def test_in_process_resume_stays_in_one_agent_run( + project, upstream, world, spans, monkeypatch +): + """A job already done at suspend time is resumed by the fired-trigger loop + inside the same execution: one live Agent run, no Resume span.""" + upstream.script = AddScript(n_calls=1) + original = world.client + + def finishing_client(): + for inv in world.invocations: + if world.jobs[inv.job_key].state == "Pending": + world.finish(inv.job_key, "Successful", {"sum": 42}) + return original() + + import uipath.platform.resume_triggers._protocol as protocol + + monkeypatch.setattr(protocol, "UiPath", finishing_client) + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + finished = spans.get_finished_spans() + [run] = _by_name(finished, "Agent run - ") + assert attrs(run)["status"] == "successful" + assert _by_name(finished, "Resume") == [] + [inv] = world.invocations + [tool] = _by_name(finished, "Tool call - a_plus_b") + assert tool.context.span_id == int(inv.parent_span_id, 16) + assert _parent_id(tool) == run.context.span_id + assert json.loads(attrs(tool)["output"]) == {"sum": 42} + [output] = _by_name(finished, "Agent output") + assert _parent_id(output) == run.context.span_id + assert await _saved_trace(project) is None + + +async def test_items_codex_runs_itself_are_traced(project, upstream, world, spans): + """A shell command next to the durable call: a Tool call span.""" + upstream.script = SiblingScript(sibling_after=False) + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + finished = spans.get_finished_spans() + saved = await _saved_trace(project) + assert saved is not None + [shell] = _by_name(finished, "Tool call - shell") + assert _parent_id(shell) == int(saved["span_id"], 16) + assert attrs(shell)["toolType"] == "Internal" + assert "command" in json.loads(attrs(shell)["input"]) + + +async def _set_trace(project: Path, record: dict[str, Any] | None) -> None: + storage = SqliteResumableStorage(str(project / "__uipath" / "state.db")) + try: + await CodexSessionStore(storage, RUNTIME_ID).set_trace(record) + finally: + await storage.dispose() + + +async def _suspend_on_one_job(project, upstream, world) -> dict[str, Any]: + upstream.script = AddScript(n_calls=1) + result = await _run(project, {}, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + saved = await _saved_trace(project) + assert saved is not None + [inv] = world.invocations + world.finish(inv.job_key, "Successful", {"sum": 42}) + _new_robot(project) + return saved + + +async def test_resumed_spans_keep_the_execution_id( + project, upstream, world, spans, trace_file +): + """Under ``uipath eval`` the execution span's ``execution.id`` reaches every + span of a resumed execution, although its parent is the restored root.""" + saved = await _suspend_on_one_job(project, upstream, world) + before = len(spans.get_finished_spans()) + tracer = trace.get_tracer("test") + with tracer.start_as_current_span( + "execution", attributes={"execution.id": "item-1"} + ): + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + resumed = [s for s in spans.get_finished_spans()[before:] if s.name != "execution"] + names = {s.name for s in resumed} + assert {"LLM call", "Agent output", "Agent run - agent"} <= names + assert _by_name(resumed, "LLM call") + assert all(attrs(s).get("execution.id") == "item-1" for s in resumed), [ + s.name for s in resumed if attrs(s).get("execution.id") != "item-1" + ] + [run] = _by_name(resumed, "Agent run - ") + assert run.context.span_id == int(saved["span_id"], 16) + + +@pytest.mark.parametrize( + "record", + [ + None, # the trace key is gone + {"span_id": "zz", "trace_id": "q"}, # unusable ids + "no-start", # usable ids, no start time + ], +) +async def test_resume_without_a_usable_saved_agent_run_still_runs( + project, upstream, world, spans, record +): + """A missing or broken saved trace record degrades the trace, never the job.""" + saved = await _suspend_on_one_job(project, upstream, world) + restorable = record == "no-start" + if restorable: + record = {k: v for k, v in saved.items() if k != "start_time_ns"} + await _set_trace(project, record) # type: ignore[arg-type] + result = await _run(project, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL, result + finished = spans.get_finished_spans() + runs = _by_name(finished, "Agent run - ") + if restorable: + # Restored, but it cannot be completed without its start time. + assert runs == [] + llm_calls = _by_name(finished, "LLM call") + assert llm_calls + assert all(_parent_id(s) == int(saved["span_id"], 16) for s in llm_calls) + else: + # A new Agent run for this execution. + [run] = runs + assert run.context.span_id != int(saved["span_id"], 16) + assert attrs(run)["status"] == "successful" + assert _by_name(finished, "Resume") == [] + [tool] = _by_name(finished, "Tool call - a_plus_b") + assert json.loads(attrs(tool)["output"]) == {"sum": 42} + assert await _saved_trace(project) is None + + +def _fail_shim_start(monkeypatch) -> None: + """Fault the run: the gateway shim cannot start.""" + from uipath_codex_sdk.runtime.gateway import CodexGatewayShim + + async def start(self): + raise RuntimeError("no port") + + monkeypatch.setattr(CodexGatewayShim, "start", start) + + +async def test_a_failing_tool_span_cleanup_still_ends_the_agent_run( + project, upstream, world, spans, monkeypatch +): + from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime + + async def broken(self, error): + raise RuntimeError("database is locked") + + monkeypatch.setattr(UiPathCodexRuntime, "fail_open_tool_spans", broken) + _fail_shim_start(monkeypatch) + upstream.script = AddScript(n_calls=1) + with pytest.raises(RuntimeError, match="no port"): + await _run(project, {}, resume=False) + [run] = _by_name(spans.get_finished_spans(), "Agent run - ") + assert attrs(run)["status"] == "faulted" + assert run.status.status_code.name == "ERROR" + + +async def test_a_failing_tool_span_cleanup_still_completes_a_resumed_run( + project, upstream, world, spans, monkeypatch +): + from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime + + saved = await _suspend_on_one_job(project, upstream, world) + + async def broken(self, error): + raise RuntimeError("database is locked") + + monkeypatch.setattr(UiPathCodexRuntime, "fail_open_tool_spans", broken) + _fail_shim_start(monkeypatch) + with pytest.raises(RuntimeError, match="no port"): + await _run(project, None, resume=True) + finished = spans.get_finished_spans() + [run] = _by_name(finished, "Agent run - ") + assert run.context.span_id == int(saved["span_id"], 16) + assert attrs(run)["status"] == "faulted" + assert run.status.status_code.name == "ERROR" + assert await _saved_trace(project) is None diff --git a/packages/uipath-codex-sdk/tests/test_abandoned_stream.py b/packages/uipath-codex-sdk/tests/test_abandoned_stream.py new file mode 100644 index 00000000..480c9858 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_abandoned_stream.py @@ -0,0 +1,66 @@ +"""A stream closed from another context detaches its ``Agent run`` quietly.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, AsyncGenerator + +from opentelemetry import trace +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import UiPathRuntimeEvent, UiPathStreamOptions +from uipath.runtime.events import UiPathRuntimeMessageEvent + +from uipath_codex_sdk.runtime.factory import UiPathCodexResumableRuntime +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + + +class _EndlessDelegate: + """Streams messages until it is closed.""" + + async def stream( + self, input: Any = None, options: Any = None + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + while True: + yield UiPathRuntimeMessageEvent(payload={"text": "tick"}) + await asyncio.sleep(0) + + async def dispose(self) -> None: + pass + + +async def test_stream_closed_from_another_task_does_not_log_an_error(tmp_path, caplog): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + runtime = UiPathCodexResumableRuntime( + delegate=_EndlessDelegate(), # type: ignore[arg-type] + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id="rt", + ) + before = trace.get_current_span() + caplog.set_level(logging.DEBUG) + try: + stream = runtime.stream({}, UiPathStreamOptions()) + + async def consume() -> None: + await stream.__anext__() # the Agent run context is attached here + + # Each task runs in its own copy of the context: the generator is + # started in one and abandoned (closed) from another. + await asyncio.create_task(consume()) + await asyncio.create_task(stream.aclose()) + finally: + await storage.dispose() + + errors = [ + r + for r in caplog.records + if r.name == "opentelemetry.context" and r.levelno >= logging.ERROR + ] + assert not errors, [r.getMessage() for r in errors] + assert any( + "could not detach the agent run context" in r.getMessage() + for r in caplog.records + ) + # This task's context was never touched. + assert trace.get_current_span() is before diff --git a/packages/uipath-codex-sdk/tests/test_analyze_files.py b/packages/uipath-codex-sdk/tests/test_analyze_files.py new file mode 100644 index 00000000..c7b3946f --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_analyze_files.py @@ -0,0 +1,346 @@ +"""Analyze Files: downloads, content parts, the gateway call and its spans.""" + +from __future__ import annotations + +import base64 +import json +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +import uipath_codex_sdk.attachment_tools as at +from uipath_codex_sdk import ( + AttachmentStore, + ToolContext, + UiPathAttachment, + UiPathToolError, + analyze_files_tool_from_resource, + uipath_analyze_files_tool, +) +from uipath_codex_sdk.attachments import attachment_paths +from uipath_codex_sdk.runtime import telemetry +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError + +PNG_ID = "aaaaaaaa-0000-0000-0000-000000000001" +PDF_ID = "aaaaaaaa-0000-0000-0000-000000000002" +PNG = {"ID": PNG_ID, "FullName": "chart.png", "MimeType": "image/png"} +PDF = {"ID": PDF_ID, "FullName": "q3 report.pdf", "MimeType": "application/pdf"} +BLOBS = {PNG_ID: b"\x89PNG-bytes", PDF_ID: b"%PDF-1.7 bytes"} + +RESOURCE = { + "$resourceType": "tool", + "type": "Internal", + "name": "Analyze Files", + "description": "Analyze the attached files.", + "inputSchema": at.analyze_files_input_schema(), + "outputSchema": {"type": "object", "properties": {"analysis": {"type": "string"}}}, + "properties": {"toolType": "analyze-attachments"}, + "settings": {}, +} + + +class Gateway: + """The LLM Gateway client: records requests, answers with ``reply``.""" + + def __init__(self, status: int = 200, reply: str = "The total is 42.") -> None: + self.status, self.reply = status, reply + self.requests: list[dict[str, Any]] = [] + self.models: list[str] = [] + self.closed = 0 + + async def request(self, method: str, path: str, *, headers, content) -> Any: + self.requests.append({"headers": headers, "body": json.loads(content)}) + body = ( + { + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": self.reply}], + } + ], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + if self.status < 400 + else {"error": {"message": "unsupported file type"}} + ) + return httpx.Response(self.status, json=body) + + async def aclose(self) -> None: + self.closed += 1 + + +@pytest.fixture +def services(monkeypatch): + gateway = Gateway() + sizes: dict[str, int] = {} + + async def blob_uri(*, key): + return SimpleNamespace(uri=f"https://blob.test/{key}", name="x") + + client = SimpleNamespace( + attachments=SimpleNamespace(get_blob_file_access_uri_async=blob_uri) + ) + + def handler(request: httpx.Request) -> httpx.Response: + key = request.url.path.strip("/") + data = BLOBS[key] * sizes.get(key, 1) + return httpx.Response(200, content=data) + + async def gateway_client(model: str) -> Gateway: + gateway.models.append(model) + return gateway + + monkeypatch.setattr(at, "_uipath_client", lambda: client) + monkeypatch.setattr( + at, + "_http_client", + lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + monkeypatch.setattr(at, "_gateway_client", gateway_client) + return SimpleNamespace(gateway=gateway, sizes=sizes) + + +@pytest.fixture +def spans(monkeypatch): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(telemetry, "get_tracer", lambda: provider.get_tracer("t")) + return exporter + + +def _ctx(model: str | None = "gpt-agent") -> ToolContext: + store = AttachmentStore( + [UiPathAttachment.model_validate(PNG), UiPathAttachment.model_validate(PDF)] + ) + return ToolContext("call_af", "rt", "Analyze_Files", None, store, model) + + +async def test_files_go_to_the_agent_model_as_content_parts(services, spans): + tool = uipath_analyze_files_tool() + assert tool.name == "Analyze_Files" and tool.tool_type == "Internal" + assert attachment_paths(tool.input_schema) == [("attachments", "*")] + text = await tool.call( + {"attachments": [PNG, PDF], "analysisTask": "What is the total?"}, _ctx() + ) + assert json.loads(text) == {"analysisResult": "The total is 42."} + [request] = services.gateway.requests + assert services.gateway.models == ["gpt-agent"] and services.gateway.closed == 1 + body = request["body"] + assert body["instructions"] == at.ANALYZE_FILES_SYSTEM_MESSAGE + assert body["stream"] is False and body["model"] == "gpt-agent" + [message] = body["input"] + task, image, document = message["content"] + assert task == {"type": "input_text", "text": "What is the total?"} + assert image == { + "type": "input_image", + "image_url": "data:image/png;base64," + + base64.b64encode(BLOBS[PNG_ID]).decode(), + } + assert document["type"] == "input_file" + assert document["filename"] == "q3 report-pdf" # LangChain's sanitizing + assert document["file_data"].startswith("data:application/pdf;base64,") + + finished = {s.name: s for s in spans.get_finished_spans()} + internal, llm = finished["Analyze Files"], finished["LLM call"] + assert llm.parent.span_id == internal.context.span_id + assert internal.attributes["span_type"] == "toolCall" + assert internal.attributes["toolName"] == "Analyze Files" + assert internal.attributes["tool.name"] == "Analyze Files" + assert internal.attributes["toolType"] == "Internal" + # Not a second call to the tool-call evaluators: the call is "Tool call". + from uipath.eval._helpers.evaluators_helpers import extract_tool_calls_names + + assert internal.attributes["tool.synthesized"] is True + assert extract_tool_calls_names([internal]) == [] + assert json.loads(internal.attributes["output"]) == { + "analysisResult": "The total is 42." + } + assert llm.attributes["span_type"] == "llmCall" + assert llm.attributes["input"] == "What is the total?" + assert llm.attributes["model"] == "gpt-agent" + sent = json.loads(llm.attributes["attachments"]) + assert [(a["id"], a["direction"]) for a in sent] == [(PNG_ID, 1), (PDF_ID, 1)] + assert "base64" not in json.dumps(dict(llm.attributes)) + + +async def test_no_attachments_short_circuits(services): + tool = uipath_analyze_files_tool(model="gpt-fixed") + text = await tool.call({"attachments": [], "analysisTask": "x"}, _ctx()) + assert json.loads(text) == {"analysisResult": "No attachments provided to analyze."} + assert services.gateway.requests == [] + with pytest.raises(UiPathToolError, match="analysisTask"): + await tool.call({"attachments": [PNG], "analysisTask": " "}, _ctx()) + + +async def test_a_file_over_the_limit_fails_the_run(services): + tool = uipath_analyze_files_tool() + tool.max_file_bytes = 64 + services.sizes[PDF_ID] = 10 # 140 bytes + with pytest.raises(UiPathCodexRuntimeError) as info: + await tool.call({"attachments": [PDF], "analysisTask": "x"}, _ctx()) + assert info.value.error_info.title == "File attachment too large" + assert "FILE_ERROR" in str(info.value.error_info.code) + assert services.gateway.requests == [] + + +async def test_gateway_errors_and_unsupported_types_are_tool_errors(services): + tool = uipath_analyze_files_tool(model="gpt-fixed") + services.gateway.status = 400 + with pytest.raises(UiPathToolError, match="unsupported file type"): + await tool.call({"attachments": [PDF], "analysisTask": "x"}, _ctx()) + assert services.gateway.models == ["gpt-fixed"] + tiff = {"ID": PNG_ID, "FullName": "scan.tiff", "MimeType": "image/tiff"} + with pytest.raises(UiPathToolError, match="cannot analyze"): + await tool.call({"attachments": [tiff], "analysisTask": "x"}, _ctx()) + + +def test_from_resource(): + tool = analyze_files_tool_from_resource(RESOURCE) + assert tool.name == "Analyze_Files" and tool.display_name == "Analyze Files" + assert tool.description == "Analyze the attached files." + assert tool.input_schema["required"] == ["attachments", "analysisTask"] + with pytest.raises(ValueError, match="internal tool"): + analyze_files_tool_from_resource( + {**RESOURCE, "properties": {"toolType": "batch-transform"}} + ) + with pytest.raises(ValueError, match="disabled"): + analyze_files_tool_from_resource({**RESOURCE, "isEnabled": False}) + + +async def test_from_resource_with_attachments_from_the_agent_input(services): + tool = analyze_files_tool_from_resource( + { + **RESOURCE, + "argumentProperties": { + "attachments": { + "variant": "argument", + "argumentPath": "$.document", + "isSensitive": False, + } + }, + } + ) + assert tool.input_schema["required"] == ["analysisTask"] + ctx = ToolContext( + "c", "rt", "Analyze_Files", {"document": PDF}, _ctx().attachments, "gpt-agent" + ) + text = await tool.call({"analysisTask": "Summarize"}, ctx) + assert json.loads(text)["analysisResult"] == "The total is 42." + [request] = services.gateway.requests + assert request["body"]["input"][0]["content"][1]["type"] == "input_file" + + +async def test_from_resource_jsonpath_keys_and_array_builder(services): + # argumentProperties keys as Agent Builder writes them: JSONPath, and + # the attachments list built from the agent input. + tool = analyze_files_tool_from_resource( + { + **RESOURCE, + "argumentProperties": { + "$['attachments']": {"variant": "arrayBuilder"}, + "$['attachments'][0]": { + "variant": "argument", + "argumentPath": "$['document']", + "isSensitive": False, + }, + "$['analysisTask']": { + "variant": "static", + "value": "Summarize", + "isSensitive": False, + }, + }, + } + ) + assert set(tool.fixed_args) == {"attachments", "analysisTask"} + assert tool.input_schema["required"] == [] + ctx = ToolContext( + "c", "rt", "Analyze_Files", {"document": PDF}, _ctx().attachments, "gpt-agent" + ) + text = await tool.call({}, ctx) + assert json.loads(text)["analysisResult"] == "The total is 42." + [request] = services.gateway.requests + assert request["body"]["input"][0]["content"][1]["type"] == "input_file" + + +def _from_input_tool(): + """Analyze Files whose ``attachments`` come from the agent input's ``document``.""" + return analyze_files_tool_from_resource( + { + **RESOURCE, + "argumentProperties": { + "attachments": { + "variant": "argument", + "argumentPath": "$.document", + "isSensitive": False, + } + }, + } + ) + + +def _input_ctx(agent_input: dict[str, Any] | None) -> ToolContext: + return ToolContext( + "c", "rt", "Analyze_Files", agent_input, _ctx().attachments, "gpt-agent" + ) + + +def test_argument_property_stays_visible_and_optional(): + tool = _from_input_tool() + schema = tool.input_schema + assert list(schema["properties"]) == ["attachments", "analysisTask"] + assert ( + "Set from the agent input 'document'" + in (schema["properties"]["attachments"]["description"]) + ) + # Still an attachment field: the host resolves what the model passes. + assert attachment_paths(schema) == [("attachments", "*")] + assert tool.validate_args({"analysisTask": "x"}) == {"analysisTask": "x"} + ctx = _input_ctx({"document": PDF}) + assert tool.span_args({"analysisTask": "x"}, ctx) == { + "analysisTask": "x", + "attachments": PDF, + } + + +async def test_unresolved_argument_property_keeps_the_model_attachments(services): + tool = _from_input_tool() + text = await tool.call( + {"attachments": [PNG], "analysisTask": "Describe"}, _input_ctx({}) + ) + assert json.loads(text)["analysisResult"] == "The total is 42." + [request] = services.gateway.requests + assert request["body"]["input"][0]["content"][1]["type"] == "input_image" + # The agent input wins when it has a value. + await tool.call( + {"attachments": [PNG], "analysisTask": "Describe"}, + _input_ctx({"document": PDF}), + ) + assert services.gateway.requests[1]["body"]["input"][0]["content"][1]["type"] == ( + "input_file" + ) + + +async def test_unresolved_required_argument_property_is_a_tool_error(services): + tool = _from_input_tool() + with pytest.raises(UiPathToolError) as info: + await tool.call({"analysisTask": "Describe"}, _input_ctx({})) + assert str(info.value) == ( + "Invalid arguments for tool 'Analyze_Files': missing required " + "argument(s): attachments. The agent input has no value for them; fill " + "them in and call the tool again." + ) + assert services.gateway.requests == [] + + +def test_trace_provider_untouched(): + # The fixture patches the package tracer only. + assert trace.get_tracer_provider() is not None diff --git a/packages/uipath-codex-sdk/tests/test_attachments.py b/packages/uipath-codex-sdk/tests/test_attachments.py new file mode 100644 index 00000000..9e595141 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_attachments.py @@ -0,0 +1,375 @@ +"""Job attachments: discovery, the known-attachment store, schemas, prompt, +output, tool-host resolution and process tools.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from pydantic import BaseModel + +from uipath_codex_sdk import ( + AttachmentStore, + UiPathAttachment, + UiPathCodexAgent, + uipath_process_tool, +) +from uipath_codex_sdk.attachments import ( + attachment_paths, + not_found_message, + span_attachments, +) +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError +from uipath_codex_sdk.runtime.runtime import ( + ATTACHMENT_INSTRUCTIONS, + OUTPUT_ATTACHMENT_INSTRUCTIONS, + UiPathCodexRuntime, +) +from uipath_codex_sdk.runtime.schema import ( + get_entrypoint_schema, + to_strict_output_schema, +) + +from ._typing import attrs +from .test_tool_host import _provider, _text, make_host # noqa: F401 - fixture + +INVOICE_ID = "11111111-2222-3333-4444-000000000001" +REPORT_ID = "11111111-2222-3333-4444-000000000002" +UNKNOWN_ID = "11111111-2222-3333-4444-00000000ffff" +INVOICE = {"ID": INVOICE_ID, "FullName": "invoice.pdf", "MimeType": "application/pdf"} +REPORT = {"ID": REPORT_ID, "FullName": "report.md", "MimeType": "text/markdown"} + + +class Input(BaseModel): + invoice: UiPathAttachment + extra: list[UiPathAttachment] = [] + question: str + + +class Output(BaseModel): + summary: str + report: UiPathAttachment | None = None + + +def _attachment(data: dict[str, Any]) -> UiPathAttachment: + return UiPathAttachment.model_validate(data) + + +def _runtime(**agent: Any) -> UiPathCodexRuntime: + return UiPathCodexRuntime( + agent=UiPathCodexAgent(model="m", **agent), + runtime_id="rt", + entrypoint="agent", + file_path="/tmp/main.py", + ) + + +# --- discovery ---------------------------------------------------------------- + + +def test_paths_of_pydantic_platform_and_raw_schemas(): + assert attachment_paths(Input.model_json_schema()) == [("invoice",), ("extra", "*")] + platform = get_entrypoint_schema( + UiPathCodexAgent(model="m", input_schema=Input), "agent" + ).input + assert attachment_paths(platform) == [("invoice",), ("extra", "*")] + raw = { + "type": "object", + "properties": { + "doc": {"anyOf": [{"$ref": "#/$defs/File"}, {"type": "null"}]}, + "nested": { + "type": "object", + "properties": {"file": {"x-uipath-resource-kind": "JobAttachment"}}, + }, + "plain": {"type": "string"}, + }, + "$defs": {"File": {"type": "object", "title": "UiPathAttachment"}}, + } + assert attachment_paths(raw) == [("doc",), ("nested", "file")] + assert attachment_paths({"type": "object", "properties": {"a": {}}}) == [] + + +def test_platform_schemas_carry_the_job_attachment_definition(): + schema = get_entrypoint_schema( + UiPathCodexAgent(model="m", input_schema=Input, output_schema=Output), "agent" + ) + assert schema.input["properties"]["invoice"] == { + "$ref": "#/definitions/job-attachment" + } + definition = schema.input["definitions"]["job-attachment"] + assert definition["x-uipath-resource-kind"] == "JobAttachment" + assert definition["required"] == ["ID"] + assert schema.output["properties"]["report"] == { + "$ref": "#/definitions/job-attachment" + } + + +def test_strict_output_schema_asks_for_a_known_id_only(): + strict = to_strict_output_schema(Output.model_json_schema()) + [attachment] = strict["$defs"].values() + assert attachment["properties"].keys() == {"ID"} + assert attachment["required"] == ["ID"] + assert attachment["additionalProperties"] is False + assert strict["required"] == ["summary", "report"] + assert "Metadata" not in json.dumps(strict) + + +def test_strict_output_schema_moves_described_refs_into_any_of(): + from pydantic import Field + + class Described(BaseModel): + report: UiPathAttachment = Field(description="The report") + + strict = to_strict_output_schema(Described.model_json_schema()) + report = strict["properties"]["report"] + assert "$ref" not in report and report["description"] == "The report" + assert report["anyOf"] == [{"$ref": "#/$defs/Attachment"}] + + +def test_tool_schema_from_a_model_uses_the_reference(): + tool = uipath_process_tool("p", input_schema=Input) + assert tool.input_schema["properties"]["invoice"] == { + "$ref": "#/definitions/job-attachment" + } + assert attachment_paths(tool.input_schema) == [("invoice",), ("extra", "*")] + + +# --- the store ---------------------------------------------------------------- + + +def test_resolve_fills_in_known_attachments_and_reports_unknown_ones(): + store = AttachmentStore([_attachment(INVOICE)]) + paths = [("invoice",), ("extra", "*")] + args = { + "invoice": {"ID": INVOICE_ID}, + "extra": [json.dumps({"ID": INVOICE_ID.upper()}), {"ID": UNKNOWN_ID}], + "question": "q", + } + resolved, errors = store.resolve(paths, args) + assert resolved["invoice"] == INVOICE + assert resolved["extra"][0] == INVOICE # a JSON string and any case + assert errors == [not_found_message(UNKNOWN_ID)] + assert "Could not find JobAttachment with ID='" in errors[0] + assert args["invoice"] == {"ID": INVOICE_ID} # not changed in place + _, errors = store.resolve(paths, {"invoice": {"ID": "not-a-uuid"}}) + assert errors == [not_found_message("not-a-uuid")] + + +def test_harvest_and_round_trip(): + store = AttachmentStore() + found = store.harvest( + [("files", "*")], {"files": [REPORT, {"ID": "bad"}, None], "other": INVOICE} + ) + assert [str(a.id) for a in found] == [REPORT_ID] + assert store.changed and REPORT_ID in store and INVOICE_ID not in store + again = AttachmentStore.from_json(json.loads(json.dumps(store.to_json()))) + assert again.to_json() == [REPORT] and not again.changed + + +def test_span_attachments_shape(): + value = span_attachments([_attachment(INVOICE)], "in") + assert value is not None + assert json.loads(value) == [ + { + "id": INVOICE_ID, + "fileName": "invoice.pdf", + "mimeType": "application/pdf", + "provider": 0, + "direction": 1, + } + ] + out = span_attachments([_attachment(INVOICE)], "out") + assert out is not None + assert json.loads(out)[0]["direction"] == 2 + assert span_attachments([], "in") is None + + +# --- prompt, instructions, output ---------------------------------------------- + + +def test_prompt_renders_attachments_as_json(): + runtime = _runtime( + input_schema=Input, + prompt="Answer {question} about {invoice} (named {invoice[FullName]}).", + ) + text = runtime._render_prompt( + {"invoice": INVOICE, "extra": [REPORT], "question": "the total"} + ) + first, block = text.split("\n\n") + assert first == ( + f"Answer the total about {json.dumps(INVOICE, separators=(',', ':'))} " + "(named invoice.pdf)." + ) + # ``extra`` is not in the template: listed after it. + assert block.startswith("Input attachments (JobAttachment objects):") + assert json.loads(block.split("\n", 1)[1]) == [REPORT] + + +def test_prompt_without_a_template_lists_the_attachments(): + class Ask(BaseModel): + prompt: str + file: UiPathAttachment + + runtime = _runtime(input_schema=Ask) + text = runtime._render_prompt({"prompt": "Summarize it.", "file": INVOICE}) + assert text.startswith("Summarize it.\n\nInput attachments") + assert INVOICE_ID in text + + +def test_input_attachments_and_instructions(): + runtime = _runtime(input_schema=Input, output_schema=Output, prompt="{question}") + found = runtime.input_attachments({"invoice": INVOICE, "question": "q"}) + assert [str(a.id) for a in found] == [INVOICE_ID] + assert runtime.input_attachments({"question": "no invoice"}) == [] + assert runtime._developer_instructions({}) == ( + f"{ATTACHMENT_INSTRUCTIONS} {OUTPUT_ATTACHMENT_INSTRUCTIONS}" + ) + assert _runtime(prompt="hi")._developer_instructions({}) == "" + tool_only = _runtime(tools=[uipath_process_tool("p", input_schema=Input)]) + assert ATTACHMENT_INSTRUCTIONS in tool_only._developer_instructions({}) + + +def test_output_fills_in_a_known_attachment(): + runtime = _runtime(output_schema=Output) + runtime._attachments = AttachmentStore([_attachment(REPORT)]) + output = runtime._build_output( + json.dumps({"summary": "done", "report": {"ID": REPORT_ID}}) + ) + assert output == {"summary": "done", "report": {**REPORT, "Metadata": None}} + assert [str(a.id) for a in runtime.output_attachments(output)] == [REPORT_ID] + assert runtime._build_output(json.dumps({"summary": "s", "report": None})) == { + "summary": "s", + "report": None, + } + + +def test_output_with_an_unknown_attachment_fails_the_run(): + runtime = _runtime(output_schema=Output) + with pytest.raises(UiPathCodexRuntimeError) as info: + runtime._build_output( + json.dumps({"summary": "s", "report": {"ID": UNKNOWN_ID}}) + ) + assert "OUTPUT_INVALID" in str(info.value.error_info.code) + assert UNKNOWN_ID in info.value.error_info.detail + + +# --- tool host ------------------------------------------------------------------ + + +class AttachmentTool: + """An immediate tool with an attachment argument and attachment output.""" + + name = "inspect_file" + description = "Inspects a file." + input_schema = { + "type": "object", + "properties": { + "file": {"$ref": "#/definitions/job-attachment"}, + "note": {"type": "string"}, + }, + "required": ["file"], + "definitions": { + "job-attachment": { + "type": "object", + "x-uipath-resource-kind": "JobAttachment", + "properties": {"ID": {"type": "string"}}, + } + }, + } + output_json_schema = { + "type": "object", + "properties": {"copy": {"x-uipath-resource-kind": "JobAttachment"}}, + } + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + return dict(args) + + async def call(self, args: dict[str, Any], ctx: Any) -> str: + self.calls.append(args) + assert ctx.attachments is not None and ctx.model == "gpt-x" + return json.dumps({"copy": REPORT}) + + +async def test_host_resolves_arguments_and_harvests_results(make_host): # noqa: F811 + exporter, tracer = _provider() + store = AttachmentStore([_attachment(INVOICE)]) + tool = AttachmentTool() + h = await make_host([tool], tracer=tracer, attachments=store, model="gpt-x") + _, body = await h.call_tool("inspect_file", {"file": {"ID": INVOICE_ID}}) + assert body["result"]["isError"] is False + assert tool.calls == [{"file": INVOICE}] + assert REPORT_ID in store # harvested by the output schema + [span] = exporter.get_finished_spans() + attachments = json.loads(attrs(span)["attachments"]) + assert [(a["id"], a["direction"]) for a in attachments] == [ + (INVOICE_ID, 1), + (REPORT_ID, 2), + ] + + +async def test_host_answers_an_unknown_id_without_running_the_tool(make_host): # noqa: F811 + tool = AttachmentTool() + h = await make_host([tool], attachments=AttachmentStore(), model="gpt-x") + _, body = await h.call_tool("inspect_file", {"file": {"ID": UNKNOWN_ID}}) + assert body["result"]["isError"] is True + assert _text(body) == not_found_message(UNKNOWN_ID) + assert tool.calls == [] + + +async def test_a_runtime_error_in_a_tool_fails_the_run(make_host): # noqa: F811 + from uipath.runtime.errors import UiPathErrorCategory + + from uipath_codex_sdk.runtime.errors import UiPathCodexErrorCode + + error = UiPathCodexRuntimeError( + UiPathCodexErrorCode.FILE_ERROR, + "File attachment too large", + "too big", + UiPathErrorCategory.USER, + ) + + class Failing(AttachmentTool): + async def call(self, args: dict[str, Any], ctx: Any) -> str: + raise error + + h = await make_host( + [Failing()], attachments=AttachmentStore([_attachment(INVOICE)]) + ) + _, body = await h.call_tool("inspect_file", {"file": {"ID": INVOICE_ID}}) + assert body["result"]["isError"] is True + assert h.host.fatal_error is error and h.host.fatal.is_set() + + +# --- process tools ---------------------------------------------------------------- + + +async def test_process_tool_links_attachments_to_the_job(monkeypatch): + from types import SimpleNamespace + + from uipath.platform.orchestrator import Job + + import uipath_codex_sdk.tools as tools_mod + from uipath_codex_sdk import DurableToolContext + + calls: list[dict[str, Any]] = [] + + async def invoke(**kwargs: Any) -> Job: + calls.append(kwargs) + return Job.model_validate({"Id": 1, "Key": "k", "State": "Pending"}) + + monkeypatch.setattr( + tools_mod, + "_uipath_client", + lambda: SimpleNamespace(processes=SimpleNamespace(invoke_async=invoke)), + ) + tool = uipath_process_tool("Summarize", input_schema=Input) + args = tool.validate_args({"invoice": INVOICE, "extra": [REPORT], "question": "q"}) + assert args["invoice"] == {**INVOICE, "Metadata": None} # the wire shape + await tool.start(args, DurableToolContext("c", "rt", "Summarize")) + [call] = calls + assert [str(a.id) for a in call["attachments"]] == [INVOICE_ID, REPORT_ID] + assert call["input_arguments"]["invoice"]["ID"] == INVOICE_ID diff --git a/packages/uipath-codex-sdk/tests/test_batch_transform_attachments.py b/packages/uipath-codex-sdk/tests/test_batch_transform_attachments.py new file mode 100644 index 00000000..0645d13d --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_batch_transform_attachments.py @@ -0,0 +1,469 @@ +"""Batch Transform on a job attachment: builders, start and finish.""" + +from __future__ import annotations + +import dataclasses +import json +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +import uipath_codex_sdk.batch_transform_tools as bt +import uipath_codex_sdk.context_tools as ct +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + AttachmentStore, + DurableToolContext, + UiPathAttachment, + UiPathDurableToolError, + UiPathToolError, + batch_transform_tool_from_resource, + uipath_batch_transform_attachments_tool, +) +from uipath_codex_sdk.attachments import attachment_paths +from uipath_codex_sdk.runtime.session_store import rebuild_suspend_value +from uipath_codex_sdk.runtime.tool_host import output_paths + +CSV_ID = "dededede-0000-0000-0000-000000000001" +CSV = {"ID": CSV_ID, "FullName": "reviews.csv", "MimeType": "text/csv"} +COLUMNS = [("sentiment", "positive, neutral or negative")] + +#: Agent Builder's default resource, with a query and a column filled in. +RESOURCE: dict[str, Any] = { + "$resourceType": "tool", + "type": "internal", + "name": "Batch Transform", + "description": "Process and transform CSV files.", + "inputSchema": bt.batch_transform_attachments_input_schema(), + "outputSchema": {"type": "array", "items": {}}, + "properties": { + "toolType": "batch-transform", + "settings": { + "contextType": "attachments", + "query": {"variant": "dynamic", "description": "What to add"}, + "folderPathPrefix": {}, + "fileExtension": {"value": "csv"}, + "outputColumns": [{"name": "topic", "description": "The product area"}], + "webSearchGrounding": {"value": "Enabled"}, + }, + }, + "settings": {}, +} + + +class _Grounding: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def start_batch_transform_from_attachment_async(self, **kwargs: Any) -> Any: + from uipath.platform.context_grounding import BatchTransformCreationResponse + + self.calls.append(kwargs) + return BatchTransformCreationResponse.model_validate( + {"id": "bt-1", "lastBatchRagStatus": "Queued"} + ) + + +class _Attachments: + def __init__(self, failures: int = 0) -> None: + self.failures = failures + self.uploads: list[dict[str, Any]] = [] + + async def upload_async(self, **kwargs: Any) -> Any: + if self.failures: + self.failures -= 1 + raise RuntimeError("blob store unavailable") + self.uploads.append(kwargs) + return "11111111-0000-0000-0000-00000000aaaa" + + +class _Jobs: + def __init__(self, error: Exception | None = None) -> None: + self.links: list[dict[str, Any]] = [] + self.error = error + + async def link_attachment_async(self, **kwargs: Any) -> None: + if self.error is not None: + raise self.error + self.links.append(kwargs) + + +@pytest.fixture +def client(monkeypatch, tmp_path): + holder = SimpleNamespace( + context_grounding=_Grounding(), attachments=_Attachments(), jobs=_Jobs() + ) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: holder) + monkeypatch.setattr(ct, "_folder_keys", {}) + monkeypatch.setattr(bt.asyncio, "sleep", _no_sleep) + monkeypatch.chdir(tmp_path) + for var in ("UIPATH_FOLDER_PATH", "UIPATH_JOB_KEY"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-job") + return holder + + +async def _no_sleep(_: float) -> None: + return None + + +def _ctx() -> DurableToolContext: + store = AttachmentStore([UiPathAttachment.model_validate(CSV)]) + return DurableToolContext("call_1", "rt", "Batch_Transform", None, store, "m") + + +def _result(task_id: str = "bt-1", text: str = "a,b\n") -> str: + path = Path(bt.RESULT_DIR) / f"{task_id}.csv" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return f"Batch transform completed. Modified file available at {path.resolve()}" + + +# --- builders --------------------------------------------------------------- + + +def test_factory_schema_and_columns(): + tool = uipath_batch_transform_attachments_tool(output_columns=COLUMNS) + assert tool.name == "Batch_Transform" and tool.tool_type == "ContextGrounding" + assert tool.input_schema["required"] == ["attachment", "query"] + assert tool.input_schema["properties"]["query"]["description"] == ( + bt.BATCH_TRANSFORM_QUERY_DESCRIPTION + ) + assert attachment_paths(tool.input_schema) == [("attachment",)] + assert output_paths(tool) == [("result",)] # the result becomes known + with pytest.raises(ValueError, match="at least one output column"): + uipath_batch_transform_attachments_tool(output_columns=[]) + with pytest.raises(ValueError, match="output column 'bad/name'"): + uipath_batch_transform_attachments_tool(output_columns=[("bad/name", "d")]) + with pytest.raises(ValueError, match="output column 'x'"): + uipath_batch_transform_attachments_tool(output_columns=[("x", "")]) + + +def test_from_resource(): + tool = batch_transform_tool_from_resource(RESOURCE) + assert tool.name == "Batch_Transform" and tool.query is None + assert tool.web_search_grounding is True + assert [c.name for c in tool.output_columns] == ["topic"] + assert tool.input_schema["properties"]["query"]["description"] == "What to add" + argument = json.loads(json.dumps(RESOURCE)) + argument["properties"]["settings"]["query"] = { + "variant": "argument", + "value": "{{task}}", + } + # As langchain: any non-static query is written by the model. + assert ( + "query" in batch_transform_tool_from_resource(argument).input_schema["required"] + ) + static_empty = json.loads(json.dumps(RESOURCE)) + static_empty["properties"]["settings"]["query"] = {"variant": "static"} + with pytest.raises(ValueError, match="make the query dynamic"): + batch_transform_tool_from_resource(static_empty) + no_columns = json.loads(json.dumps(RESOURCE)) + no_columns["properties"]["settings"]["outputColumns"] = [] + with pytest.raises(ValueError, match="at least one output column"): + batch_transform_tool_from_resource(no_columns) + + +def test_suspend_value_round_trip(): + from uipath.core.serialization import serialize_object + from uipath.platform.context_grounding import BatchTransformCreationResponse + + value = bt.WaitBatchTransformFile( + batch_transform=BatchTransformCreationResponse.model_validate( + {"id": "bt-1", "lastBatchRagStatus": "Queued"} + ), + index_folder_key="fk", + destination_path=".uipath/batch_transform/bt-1.csv", + ) + again = rebuild_suspend_value( + "WaitBatchTransformFile", json.loads(json.dumps(serialize_object(value))) + ) + assert isinstance(again, bt.WaitBatchTransformFile) + assert again.batch_transform.id == "bt-1" and again.destination_path.endswith( + "bt-1.csv" + ) + + +# --- start ------------------------------------------------------------------ + + +async def test_start(client): + tool = uipath_batch_transform_attachments_tool( + output_columns=COLUMNS, web_search_grounding=True + ) + args = tool.validate_args({"attachment": CSV, "query": "Classify."}) + value = await tool.start(args, _ctx()) + assert isinstance(value, bt.WaitBatchTransformFile) + assert value.index_folder_key == "fk-job" + assert value.destination_path == os.path.join(bt.RESULT_DIR, "bt-1.csv") + [call] = client.context_grounding.calls + assert call["attachment"] == CSV_ID and call["prompt"] == "Classify." + assert call["enable_web_search_grounding"] is True + assert (call["folder_key"], call["folder_path"]) == ("fk-job", None) + assert call["name"].startswith("task-") + assert tool.started_output(value) == { + "status": "started", + "batch_transform_id": "bt-1", + "batch_transform_status": "Queued", + } + + +async def test_from_resource_jsonpath_argument_property(client): + data = json.loads(json.dumps(RESOURCE)) + data["argumentProperties"] = { + "$['attachment']": { + "variant": "argument", + "argumentPath": "$['file']", + "isSensitive": False, + } + } + tool = batch_transform_tool_from_resource(data) + assert set(tool.fixed_args) == {"attachment"} + assert "attachment" in tool.input_schema["properties"] # visible, optional + assert tool.input_schema["required"] == ["query"] + ctx = dataclasses.replace(_ctx(), agent_input={"file": CSV}) + await tool.start({"query": "Classify."}, ctx) + [call] = client.context_grounding.calls + assert call["attachment"] == CSV_ID + + +async def test_unresolved_attachment_argument(client): + # langchain's StaticArgsHandler: an argument the agent input lacks keeps + # the model's value; a required one neither filled is a tool error. + tool = uipath_batch_transform_attachments_tool( + output_columns=COLUMNS, attachment_from_input="{{file}}" + ) + schema = tool.input_schema + assert schema["required"] == ["query"] + assert ( + "Set from the agent input 'file'" + in (schema["properties"]["attachment"]["description"]) + ) + assert attachment_paths(schema) == [("attachment",)] + other = "dededede-0000-0000-0000-00000000ffff" + with_input = dataclasses.replace(_ctx(), agent_input={"file": CSV}) + await tool.start({"attachment": {"ID": other}, "query": "q"}, with_input) + assert client.context_grounding.calls[-1]["attachment"] == CSV_ID + without = dataclasses.replace(_ctx(), agent_input={}) + await tool.start({"attachment": {"ID": CSV_ID}, "query": "q"}, without) + assert client.context_grounding.calls[-1]["attachment"] == CSV_ID + calls = len(client.context_grounding.calls) + with pytest.raises(UiPathDurableToolError) as info: + await tool.start(tool.validate_args({"query": "q"}), without) + assert str(info.value) == ( + "Invalid arguments for tool 'Batch_Transform': missing required " + "argument(s): attachment. The agent input has no value for them; fill " + "them in and call the tool again." + ) + assert len(client.context_grounding.calls) == calls + merged = {"query": "q", "attachment": CSV} + assert tool.span_args({"query": "q"}, with_input) == merged + assert tool.mock_args({"query": "q"}, with_input) == merged + with pytest.raises(UiPathDurableToolError, match="missing required"): + tool.mock_args({"query": "q"}, without) + + +async def test_start_errors(client, monkeypatch): + tool = uipath_batch_transform_attachments_tool(output_columns=COLUMNS) + with pytest.raises(UiPathDurableToolError, match="Query is required"): + await tool.start({"attachment": CSV, "query": " "}, _ctx()) + with pytest.raises(UiPathDurableToolError, match="Attachment is required"): + await tool.start({"attachment": None, "query": "q"}, _ctx()) + with pytest.raises(ValueError, match="longer than"): + tool.validate_args({"attachment": CSV, "query": "x" * 250_001}) + monkeypatch.delenv("UIPATH_FOLDER_KEY") + with pytest.raises(UiPathDurableToolError, match="needs a folder"): + await tool.start({"attachment": CSV, "query": "q"}, _ctx()) + assert client.context_grounding.calls == [] + + +# --- finish ----------------------------------------------------------------- + + +async def test_finish_uploads_and_links(client, monkeypatch): + monkeypatch.setenv("UIPATH_JOB_KEY", "abababab-0000-0000-0000-000000000001") + tool = uipath_batch_transform_attachments_tool(output_columns=COLUMNS) + client.attachments.failures = 1 # retried + text = await tool.finish({}, _result()) + assert json.loads(text) == { + "result": { + "ID": "11111111-0000-0000-0000-00000000aaaa", + "FullName": "output.csv", + "MimeType": "text/csv", + } + } + [upload] = client.attachments.uploads + assert upload["name"] == "output.csv" and upload["folder_key"] == "fk-job" + [link] = client.jobs.links + assert str(link["job_key"]) == "abababab-0000-0000-0000-000000000001" + assert not (Path(bt.RESULT_DIR) / "bt-1.csv").exists() + + +async def test_finish_keeps_the_upload_when_the_link_fails(client, monkeypatch): + monkeypatch.setenv("UIPATH_JOB_KEY", "abababab-0000-0000-0000-000000000001") + client.jobs.error = RuntimeError("link refused") + tool = uipath_batch_transform_attachments_tool(output_columns=COLUMNS) + result = json.loads(await tool.finish({}, _result())) + assert result["result"]["ID"] == "11111111-0000-0000-0000-00000000aaaa" + + +async def test_finish_local_run_and_bad_values(client, tmp_path): + tool = uipath_batch_transform_attachments_tool(output_columns=COLUMNS) + await tool.finish({}, _result()) # no job key: uploaded, not linked + assert len(client.attachments.uploads) == 1 and client.jobs.links == [] + with pytest.raises(UiPathToolError, match="Unexpected"): + await tool.finish({}, {"path": "/etc/passwd"}) + outside = tmp_path / "secret.csv" + outside.write_text("x") + with pytest.raises(UiPathToolError, match="not available"): + await tool.finish( + {}, f"Batch transform completed. Modified file available at {outside}" + ) + client.attachments.failures = 3 + with pytest.raises(RuntimeError, match="blob store"): + await tool.finish({}, _result()) + + +# --- reading the trigger and downloading the result ------------------------- + + +class _ResultService: + """retrieve / read-URI / authenticated download of one task.""" + + def __init__(self, status: str = "Successful", encrypted: bool = False) -> None: + self.status, self.encrypted = status, encrypted + self.requests: list[str] = [] + + async def retrieve_batch_transform_async(self, id: str) -> Any: + return SimpleNamespace(id=id, last_batch_rag_status=self.status) + + def _batch_transform_get_read_uri_spec(self, id: str) -> Any: + return SimpleNamespace(method="GET", endpoint="read-uri", headers={}) + + def _batch_transform_download_blob_spec(self, id: str) -> Any: + return SimpleNamespace(method="GET", endpoint="download-blob", headers={}) + + async def request_async(self, method: str, endpoint: str, **kwargs: Any) -> Any: + import httpx + + self.requests.append(endpoint) + request = httpx.Request(method, f"https://t/{endpoint}") + if endpoint == "read-uri": + body = {"uri": "https://blob.test/r.csv", "isEncrypted": self.encrypted} + return httpx.Response(200, json=body, request=request) + return httpx.Response(200, content=b"authenticated", request=request) + + +def _trigger(reader: str | None = "codex") -> Any: + from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType + + trigger = UiPathResumeTrigger.model_validate( + {"triggerType": UiPathResumeTriggerType.BATCH_RAG, "itemKey": "bt-1"} + ) + trigger.payload = { + "destination_path": "x.csv", + **({"reader": reader} if reader else {}), + } + return trigger + + +async def test_codex_trigger_reader(monkeypatch): + from uipath.core.errors import UiPathFaultedTriggerError, UiPathPendingTriggerError + + from uipath_codex_sdk.runtime.triggers import CodexResumeTriggerHandler + + service = _ResultService() + monkeypatch.setattr( + tools_mod, "_uipath_client", lambda: SimpleNamespace(context_grounding=service) + ) + handler = CodexResumeTriggerHandler() + assert await handler.read_trigger(_trigger()) == { + "batch_transform_id": "bt-1", + "status": "Successful", + } + assert service.requests == [] # nothing downloaded by the reader + service.status = "InProgress" + with pytest.raises(UiPathPendingTriggerError): + await handler.read_trigger(_trigger()) + service.status = "Failed" + with pytest.raises(UiPathFaultedTriggerError, match="failed"): + await handler.read_trigger(_trigger()) + assert not bt.is_codex_batch_transform_trigger(_trigger(reader=None)) + + +@pytest.mark.parametrize("authenticated_ok", [True, False]) +async def test_download_uses_the_authenticated_endpoint_then_the_storage_url( + monkeypatch, tmp_path, authenticated_ok +): + import httpx + import uipath._utils._ssl_context as ssl_context + + def blob(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"from storage") + + monkeypatch.setattr( + ssl_context, + "get_httpx_client_kwargs", + lambda: {"transport": httpx.MockTransport(blob)}, + ) + service = _ResultService() + if not authenticated_ok: + original = service.request_async + + async def failing(method: str, endpoint: str, **kwargs: Any) -> Any: + if endpoint == "download-blob": + service.requests.append(endpoint) + raise RuntimeError("download endpoint unavailable") + return await original(method, endpoint, **kwargs) + + service.request_async = failing # type: ignore[method-assign] + path = tmp_path / "r.csv" + await bt._download_result(SimpleNamespace(context_grounding=service), "bt-1", path) + if authenticated_ok: + assert path.read_bytes() == b"authenticated" + assert service.requests == ["download-blob"] # no storage URL needed + else: + assert path.read_bytes() == b"from storage" + assert service.requests == ["download-blob", "read-uri"] + + +async def test_download_reports_the_authenticated_error_when_both_fail( + monkeypatch, tmp_path +): + import httpx + import uipath._utils._ssl_context as ssl_context + + def unreachable(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("Name or service not known", request=request) + + monkeypatch.setattr( + ssl_context, + "get_httpx_client_kwargs", + lambda: {"transport": httpx.MockTransport(unreachable)}, + ) + service = _ResultService() + original = service.request_async + + async def failing(method: str, endpoint: str, **kwargs: Any) -> Any: + if endpoint == "download-blob": + raise RuntimeError("download endpoint unavailable") + return await original(method, endpoint, **kwargs) + + service.request_async = failing # type: ignore[method-assign] + with pytest.raises(RuntimeError, match="download endpoint unavailable"): + await bt._download_result( + SimpleNamespace(context_grounding=service), "bt-1", tmp_path / "r.csv" + ) + + +async def test_finish_downloads_a_codex_resume_value(client): + client.context_grounding = _ResultService(encrypted=True) + tool = uipath_batch_transform_attachments_tool(output_columns=COLUMNS) + text = await tool.finish({}, {"batch_transform_id": "bt-1", "status": "Successful"}) + assert json.loads(text)["result"]["FullName"] == "output.csv" + assert client.context_grounding.requests == ["download-blob"] + with pytest.raises(UiPathToolError, match="Unexpected Batch Transform id"): + await tool.finish({}, {"batch_transform_id": "../../etc"}) diff --git a/packages/uipath-codex-sdk/tests/test_context_tools.py b/packages/uipath-codex-sdk/tests/test_context_tools.py new file mode 100644 index 00000000..34a59eb1 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_context_tools.py @@ -0,0 +1,837 @@ +"""Context Grounding tools: schemas, query resolution, the search, errors, spans. + +No codex here: the tool is called directly, against a fake +``context_grounding`` service or the real SDK ``ContextGroundingService`` +with its HTTP layer captured. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest + +import uipath_codex_sdk.context_tools as ctx_mod +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + ToolContext, + UiPathContextTool, + UiPathToolError, + context_tool_from_resource, + uipath_context_tool, +) +from uipath_codex_sdk.context_tools import RESULT_CAP, _capped_json +from uipath_codex_sdk.tools import is_immediate_tool, tool_type_of + +from .durable.fakes import enriched + +CTX = ToolContext(call_id="call_1", runtime_id="rt", tool_name="Company_Policies") + +#: An agent.json context resource (low-code shape), trimmed of UI-only fields. +POLICY_RESOURCE: dict[str, Any] = { + "$resourceType": "context", + "name": "Company Policies", + "description": "HR and travel policies of the company.", + "contextType": "index", + "folderPath": "Shared", + "indexName": "company-policies", + "settings": { + "resultCount": 3, + "retrievalMode": "Semantic", + "threshold": 0.5, + "query": {"variant": "dynamic"}, + "folderPathPrefix": {"variant": "static", "value": "hr"}, + "fileExtension": {"value": "All"}, + }, +} + + +def _result(*items: dict[str, Any], operation_id: str = "op-1") -> Any: + from uipath.platform.context_grounding import UnifiedQueryResult + + return UnifiedQueryResult.model_validate( + { + "semanticResults": { + "values": list(items), + "metadata": {"operation_id": operation_id, "strategy": "s"}, + } + } + ) + + +def _item(content: str = "Travel is booked via the portal.", **kw: Any) -> dict: + return { + "content": content, + "source": kw.get("source", "travel.pdf"), + "page_number": kw.get("page_number", 2), + "score": kw.get("score", 0.83), + "reference": kw.get("reference", "ref-1"), + } + + +class FakeContextGrounding: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + self.result: Any = _result(_item()) + self.error: BaseException | None = None + + async def unified_search_async(self, **kwargs: Any) -> Any: + self.calls.append(kwargs) + if self.error is not None: + raise self.error + return self.result + + +class FakeFolders: + def __init__(self, keys: dict[str, str] | None = None) -> None: + self.keys = keys or {} + self.lookups: list[str] = [] + + async def retrieve_key_async(self, *, folder_path: str) -> str | None: + self.lookups.append(folder_path) + return self.keys.get(folder_path) + + def retrieve_key(self, *, folder_path: str) -> str | None: + raise AssertionError(f"blocking folder lookup of {folder_path!r}") + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + monkeypatch.setattr(ctx_mod, "_folder_keys", {}) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + + +@pytest.fixture +def fake(monkeypatch) -> SimpleNamespace: + client = SimpleNamespace( + context_grounding=FakeContextGrounding(), + folders=FakeFolders({"Shared": "fk-shared", "Jobs": "fk-jobs"}), + ) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + return client + + +# -------------------------------------------------------------------------- +# schemas and validation per query variant +# -------------------------------------------------------------------------- +def test_dynamic_query_schema_and_validation(): + tool = uipath_context_tool("Company Policies", index_name="company-policies") + assert tool.name == "Company_Policies" + assert is_immediate_tool(tool) + assert tool.input_schema == { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The query to search for in the knowledge base", + } + }, + "required": ["query"], + } + assert tool.validate_args({"query": "travel"}) == {"query": "travel"} + with pytest.raises(ValueError): + tool.validate_args({}) + with pytest.raises(ValueError, match="must not be empty"): + tool.validate_args({"query": " "}) + + +def test_static_query_takes_no_arguments(): + tool = uipath_context_tool("p", index_name="i", query="travel policy") + assert tool.input_schema == {"type": "object", "properties": {}} + assert tool.validate_args({"query": "ignored"}) == {} + assert tool.resolve_query({}, None) == "travel policy" + + +def test_argument_query_stays_visible_and_optional(): + tool = uipath_context_tool("p", index_name="i", query_from_input="{{topic}}") + assert tool.query_from_input == "topic" + prop = tool.input_schema["properties"]["query"] + assert "required" not in tool.input_schema + assert "agent input 'topic'" in prop["description"] + assert tool.validate_args({}) == {} + # The agent input wins; the model's query is the fallback. + assert tool.resolve_query({"query": "model"}, {"topic": "input"}) == "input" + assert tool.resolve_query({"query": "model"}, {"other": 1}) == "model" + assert tool.resolve_query({}, {"topic": ""}) is None + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"index_name": ""}, "index_name is required"), + ({"index_name": "i", "query": "q", "query_from_input": "t"}, "not both"), + ({"index_name": "i", "query": " "}, "static query is empty"), + ({"index_name": "i", "number_of_results": 0}, "at least 1"), + ({"index_name": "i", "timeout_sec": 0}, "timeout_sec must be positive"), + ], +) +def test_invalid_configurations(kwargs, match): + with pytest.raises(ValueError, match=match): + uipath_context_tool("p", **kwargs) + + +def test_span_attributes_and_tool_type(): + tool = uipath_context_tool("Company Policies", index_name="company-policies") + assert tool.span_attributes == { + "index_name": "company-policies", + "context_retrieval_mode": "Semantic", + "tool.display_name": "Company Policies", + } + assert tool_type_of(tool, tool.kind) == "ContextGrounding" + assert tool_type_of(SimpleNamespace(kind="integration")) == "Integration" + + +# -------------------------------------------------------------------------- +# the search +# -------------------------------------------------------------------------- +async def test_call_searches_and_returns_langchain_documents(fake): + tool = uipath_context_tool( + "Company Policies", + index_name="company-policies", + folder_path="Shared", + number_of_results=4, + threshold=0.2, + folder_path_prefix="hr", + file_extension="pdf", + ) + text = await tool.call(tool.validate_args({"query": "travel"}), CTX) + assert json.loads(text) == { + "documents": [ + { + "metadata": { + "source": "travel.pdf", + "search_id": "op-1", + "reference": "ref-1", + "page_number": 2, + "score": 0.83, + }, + "page_content": "Travel is booked via the portal.", + } + ] + } + [call] = fake.context_grounding.calls + scope = call.pop("scope") + assert (scope.folder, scope.extension) == ("hr", "pdf") + assert call == { + "name": "company-policies", + "query": "travel", + "search_mode": call["search_mode"], + "number_of_results": 4, + "threshold": 0.2, + "folder_key": "fk-shared", + "folder_path": None, + "include_system_indexes": False, + } + assert call["search_mode"].value == "Semantic" + + +async def test_folder_defaults_to_the_job_folder_without_a_lookup(fake, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Jobs") + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-env") + tool = uipath_context_tool("p", index_name="i") + await tool.call({"query": "q"}, CTX) + assert fake.context_grounding.calls[0]["folder_key"] == "fk-env" + assert fake.folders.lookups == [] + + +async def test_folder_key_is_looked_up_once(fake, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Jobs") # no key in the env + tool = uipath_context_tool("p", index_name="i") + await tool.call({"query": "a"}, CTX) + await tool.call({"query": "b"}, CTX) + assert fake.folders.lookups == ["Jobs"] + assert [c["folder_key"] for c in fake.context_grounding.calls] == ["fk-jobs"] * 2 + + +async def test_unknown_folder_is_left_to_the_sdk(fake): + tool = uipath_context_tool("p", index_name="i", folder_path="Nope") + await tool.call({"query": "q"}, CTX) + [call] = fake.context_grounding.calls + assert (call["folder_key"], call["folder_path"]) == (None, "Nope") + + +async def test_no_scope_and_argument_values_from_the_agent_input(fake): + tool = uipath_context_tool( + "p", + index_name="i", + query_from_input="request.topic", + folder_path_prefix_from_input="area", + file_extension="All", + ) + ctx = ToolContext( + call_id="c", + runtime_id="rt", + tool_name="p", + agent_input={"request": {"topic": "travel"}, "area": "finance"}, + ) + await tool.call({}, ctx) + [call] = fake.context_grounding.calls + assert call["query"] == "travel" + assert call["scope"].folder == "finance" and call["scope"].extension is None + tool2 = uipath_context_tool("p", index_name="i") + await tool2.call({"query": "q"}, CTX) + assert fake.context_grounding.calls[1]["scope"] is None + + +async def test_missing_query_is_a_tool_error(fake): + tool = uipath_context_tool("p", index_name="i", query_from_input="topic") + with pytest.raises(UiPathToolError, match="needs a query"): + await tool.call({}, CTX) + assert fake.context_grounding.calls == [] + + +async def test_zero_results_is_not_an_error(fake): + fake.context_grounding.result = _result() + tool = uipath_context_tool("p", index_name="i") + assert json.loads(await tool.call({"query": "q"}, CTX)) == {"documents": []} + + +def _ingesting() -> BaseException: + from uipath.platform.errors import IngestionInProgressException + + return IngestionInProgressException(index_name="company-policies") + + +def _not_found() -> BaseException: + from uipath.platform.errors import ContextGroundingIndexNotFoundError + + return ContextGroundingIndexNotFoundError("company-policies") + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (_not_found, "Context grounding index 'company-policies' was not found."), + ( + _ingesting, + "Context grounding index 'company-policies' is still being ingested. " + "Try again later.", + ), + ( + lambda: enriched(400, message="bad scope"), + "Context grounding returned an error for index 'company-policies': " + "bad scope", + ), + ( + lambda: enriched(500, message="down"), + "Failed to search context index 'company-policies' (HTTP 500): down", + ), + ( + lambda: httpx.ReadTimeout("slow"), + "Context grounding search for tool 'Company_Policies' timed out. " + "You may retry.", + ), + ], +) +async def test_errors_go_back_to_the_model(fake, error, expected): + fake.context_grounding.error = error() + tool = uipath_context_tool("Company Policies", index_name="company-policies") + with pytest.raises(UiPathToolError) as info: + await tool.call({"query": "q"}, CTX) + assert str(info.value) == expected + + +async def test_unexpected_errors_propagate(fake): + fake.context_grounding.error = RuntimeError("boom") + tool = uipath_context_tool("p", index_name="i") + with pytest.raises(RuntimeError): + await tool.call({"query": "q"}, CTX) + + +def test_result_cap_drops_whole_documents(): + docs = [{"metadata": {"n": i}, "page_content": "x" * 10_000} for i in range(10)] + text = _capped_json({"documents": docs}) + out = json.loads(text) + assert len(text) <= RESULT_CAP + assert out["truncated"] is True + assert [d["metadata"]["n"] for d in out["documents"]] == list( + range(len(out["documents"])) + ) + huge = _capped_json({"documents": [{"page_content": "y" * 100_000}]}) + assert len(huge) <= RESULT_CAP and json.loads(huge)["truncated"] is True + small = {"documents": [{"page_content": "z"}]} + assert json.loads(_capped_json(small)) == small + + +async def test_mockable_passthrough(fake, monkeypatch): + seen: list[Any] = [] + + def fake_wrap(spec: Any, fn: Any) -> Any: + seen.append(spec) + return fn + + import uipath_codex_sdk.runtime.mocking as mocking + + monkeypatch.setattr(mocking, "wrap", fake_wrap) + tool = uipath_context_tool("Company Policies", index_name="i") + await tool.call({"query": "q"}, CTX) + [spec] = seen + assert spec.name == "Company Policies" + assert spec.example_calls == [] + assert spec.output_schema["required"] == ["documents"] + # Outside an eval mocking context the search runs for real. + assert len(fake.context_grounding.calls) == 1 + + +# -------------------------------------------------------------------------- +# against the real SDK service (folder headers, resource overwrites) +# -------------------------------------------------------------------------- +class CapturedContextGrounding: + """A real ``ContextGroundingService`` whose HTTP calls are captured.""" + + def __init__(self) -> None: + from uipath.platform.common._config import UiPathApiConfig + from uipath.platform.common._execution_context import UiPathExecutionContext + from uipath.platform.context_grounding._context_grounding_service import ( + ContextGroundingService, + ) + + self.requests: list[dict[str, Any]] = [] + self.folders = FakeFolders({"Shared": "fk-shared", "Other": "fk-other"}) + self.context_grounding = ContextGroundingService( + UiPathApiConfig(base_url="https://cloud.test/org/tenant", secret="s"), + UiPathExecutionContext(), + folders_service=self.folders, # type: ignore[arg-type] + buckets_service=None, # type: ignore[arg-type] + ) + self.context_grounding.request_async = self._request # type: ignore[method-assign, assignment] + + async def _request(self, method: str, url: Any, **kwargs: Any) -> httpx.Response: + url = str(url) + self.requests.append({"method": method, "url": url, **kwargs}) + request = httpx.Request(method, "https://cloud.test" + url) + if url.startswith("/ecs_/v2/indexes"): + name = kwargs["params"]["$filter"].split("'")[1] + body: Any = { + "value": [ + { + "id": f"id-{name}", + "name": name, + "lastIngestionStatus": "Successful", + } + ] + } + else: + body = { + "semanticResults": { + "values": [_item("Ünïcode policy")], + "metadata": {"operation_id": "op-9", "strategy": "s"}, + } + } + return httpx.Response(200, json=body, request=request) + + +async def test_call_through_the_real_service(monkeypatch): + sdk = CapturedContextGrounding() + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: sdk) + tool = context_tool_from_resource(POLICY_RESOURCE) + assert isinstance(tool, UiPathContextTool) + tool.folder_path = "Shared" + text = await tool.call({"query": "travel"}, CTX) + assert "Ünïcode policy" in text # not ASCII-escaped + assert json.loads(text)["documents"][0]["metadata"]["search_id"] == "op-9" + retrieve, search = sdk.requests + assert retrieve["url"] == "/ecs_/v2/indexes" + assert "fk-shared" in retrieve["headers"].values() + assert search["url"] == "/ecs_/v1.2/search/id-company-policies" + assert search["json"] == { + "searchMode": "Semantic", + "query": "travel", + "semanticSearchOptions": {"numberOfResults": 3, "threshold": 0.5}, + "scope": {"folder": "hr"}, + } + assert "fk-shared" in search["headers"].values() + assert sdk.folders.lookups == ["Shared"] # async, never the blocking one + + +async def test_resource_overwrite_rebinds_the_index(monkeypatch): + from uipath.platform.common import ResourceOverwritesContext + from uipath.platform.common._bindings import GenericResourceOverwrite + + sdk = CapturedContextGrounding() + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: sdk) + tool = uipath_context_tool("p", index_name="company-policies", folder_path="Shared") + + async def overwrites(): + return { + "index.company-policies.Shared": GenericResourceOverwrite.model_validate( + { + "resource_type": "index", + "name": "prod-policies", + "folderPath": "Other", + } + ) + } + + async with ResourceOverwritesContext(overwrites): + await tool.call({"query": "q"}, CTX) + retrieve, search = sdk.requests + assert "'prod-policies'" in retrieve["params"]["$filter"] + assert "fk-other" in retrieve["headers"].values() + assert search["url"] == "/ecs_/v1.2/search/id-prod-policies" + assert sdk.folders.lookups == ["Other"] + assert tool.index_name == "company-policies" # never pre-resolved + + +# -------------------------------------------------------------------------- +# agent.json resources +# -------------------------------------------------------------------------- +def test_from_resource_maps_the_settings(): + tool = context_tool_from_resource(POLICY_RESOURCE) + assert isinstance(tool, UiPathContextTool) + assert tool.name == "Company_Policies" + assert tool.display_name == "Company Policies" + assert tool.description == "HR and travel policies of the company." + assert (tool.index_name, tool.number_of_results, tool.threshold) == ( + "company-policies", + 3, + 0.5, + ) + assert tool.folder_path is None # folderPath is not used, as in langchain + assert tool.folder_path_prefix == "hr" and tool.file_extension is None + assert tool.query is None and tool.query_from_input is None + assert tool.retrieval_mode == "Semantic" + + +def _with(**settings: Any) -> dict[str, Any]: + r = json.loads(json.dumps(POLICY_RESOURCE)) + r["settings"].update(settings) + return r + + +def test_from_resource_query_variants(): + static = context_tool_from_resource( + _with(query={"variant": "static", "value": "leave policy"}) + ) + assert static.query == "leave policy" and static.input_schema["properties"] == {} + argument = context_tool_from_resource( + _with(query={"variant": "argument", "value": "{{topic}}"}) + ) + assert argument.query_from_input == "topic" + no_query = json.loads(json.dumps(POLICY_RESOURCE)) + del no_query["settings"]["query"] + assert context_tool_from_resource(no_query).input_schema["required"] == ["query"] + builder = context_tool_from_resource( + _with(query={"variant": "textBuilder", "value": "x"}) + ) + assert builder.query is None and builder.query_from_input is None + prefix = context_tool_from_resource( + _with(folderPathPrefix={"variant": "argument", "value": "{{area}}"}) + ) + assert prefix.folder_path_prefix is None + assert prefix.folder_path_prefix_from_input == "area" + structured = context_tool_from_resource(_with(retrievalMode="Structured")) + assert structured.retrieval_mode == "Structured" + assert structured.span_attributes["context_retrieval_mode"] == "Structured" + + +@pytest.mark.parametrize( + ("change", "match"), + [ + ( + {"settings": {**POLICY_RESOURCE["settings"], "retrievalMode": "DeepRAG"}}, + "DeepRAG needs 'settings.citationMode'", + ), + ( + { + "settings": { + **POLICY_RESOURCE["settings"], + "retrievalMode": "BatchTransform", + } + }, + "not supported yet", + ), + ( + {"settings": {**POLICY_RESOURCE["settings"], "retrievalMode": "Unknown"}}, + "not supported yet", + ), + ({"contextType": "attachments"}, "context type 'attachments'"), + ({"indexName": None}, "has no 'indexName'"), + ({"settings": None}, "has no 'settings'"), + ( + {"settings": {**POLICY_RESOURCE["settings"], "query": {"variant": None}}}, + "the query has no variant", + ), + ( + { + "settings": { + **POLICY_RESOURCE["settings"], + "query": {"variant": "static"}, + } + }, + "static query has no value", + ), + ({"isEnabled": False}, "is disabled"), + ], +) +def test_from_resource_refusals(change, match): + with pytest.raises(ValueError, match=match): + context_tool_from_resource({**POLICY_RESOURCE, **change}) + + +def test_from_resource_model_instance_and_overrides(): + from uipath.agent.models.agent import AgentContextResourceConfig + + model = AgentContextResourceConfig.model_validate(POLICY_RESOURCE) + tool = context_tool_from_resource(model, tool_name="policies", timeout_sec=9) + assert isinstance(tool, UiPathContextTool) + assert tool.name == "policies" and tool.timeout_sec == 9 + + +# -------------------------------------------------------------------------- +# DeepRAG (durable) +# -------------------------------------------------------------------------- +def _deep_rag(status: str = "Successful", **extra: Any) -> Any: + from uipath.platform.context_grounding import DeepRagResponse + + data: dict[str, Any] = { + "id": "dr-1", + "name": "task-1", + "createdDate": "2026-10-13T00:00:00Z", + "lastDeepRagStatus": status, + "content": None, + **extra, + } + if status == "Successful" and data["content"] is None: + data["content"] = { + "text": "Owes $4,200 [1].", + "citations": [ + {"ordinal": 1, "pageNumber": 2, "source": "a.pdf", "reference": "r"} + ], + } + return DeepRagResponse.model_validate(data) + + +def test_deep_rag_tool_shape(): + from uipath_codex_sdk import UiPathDeepRagTool, uipath_deep_rag_tool + from uipath_codex_sdk.tools import is_durable_tool + + tool = uipath_deep_rag_tool("Invoice Research", index_name="invoices") + assert isinstance(tool, UiPathDeepRagTool) and is_durable_tool(tool) + assert tool.name == "Invoice_Research" + assert tool.citation_mode.value == "Inline" + assert tool.input_schema["required"] == ["query"] + assert ( + "what to research across documents" + in (tool.input_schema["properties"]["query"]["description"]) + ) + assert tool_type_of(tool, tool.kind) == "ContextGrounding" + assert tool.span_attributes["context_retrieval_mode"] == "DeepRAG" + assert tool.validate_args({"query": "q"}) == {"query": "q"} + with pytest.raises(ValueError, match="non-empty"): + tool.validate_args({"query": " "}) + with pytest.raises(ValueError, match="longer than"): + tool.validate_args({"query": "x" * 250_001}) + static = uipath_deep_rag_tool("r", index_name="i", query="Summarize") + assert static.input_schema == {"type": "object", "properties": {}} + assert static.validate_args({"query": "ignored"}) == {} + skip = uipath_deep_rag_tool("r", index_name="i", citation_mode="Skip") + assert skip.citation_mode.value == "Skip" + with pytest.raises(ValueError, match="citation_mode"): + uipath_deep_rag_tool("r", index_name="i", citation_mode="Footnotes") + + +async def test_deep_rag_finish_maps_every_outcome(): + from uipath_codex_sdk import UiPathDurableToolPending, uipath_deep_rag_tool + + tool = uipath_deep_rag_tool("r", index_name="i") + ok = json.loads(await tool.finish({}, _deep_rag())) + assert ok == { + "text": "Owes $4,200 [1].", + "citations": [ + {"ordinal": 1, "page_number": 2, "source": "a.pdf", "reference": "r"} + ], + "deepRagId": "dr-1", + } + # The serialized form (a resume value read back from storage) too. + as_dict = _deep_rag().model_dump(by_alias=True) + assert json.loads(await tool.finish({}, as_dict)) == ok + failed = json.loads( + await tool.finish({}, _deep_rag("Failed", failureReason="boom")) + ) + assert failed == {"status": "failed", "deepRagId": "dr-1", "error": "boom"} + for status in ("Queued", "InProgress"): + with pytest.raises(UiPathDurableToolPending): + await tool.finish({}, _deep_rag(status)) + assert await tool.finish({}, {"answer": "typed by hand"}) == json.dumps( + {"answer": "typed by hand"} + ) + assert await tool.finish({}, "plain") == "plain" + + +async def test_deep_rag_start_resolves_query_glob_and_folder(monkeypatch): + from uipath.platform.common import WaitDeepRagRaw + from uipath.platform.context_grounding import DeepRagCreationResponse + + from uipath_codex_sdk import DurableToolContext, uipath_deep_rag_tool + + starts: list[dict[str, Any]] = [] + + class Cg: + async def start_deep_rag_async(self, **kwargs: Any) -> Any: + starts.append(kwargs) + return DeepRagCreationResponse.model_validate( + {"id": "dr-9", "lastDeepRagStatus": "Queued", "createdDate": "x"} + ) + + client = SimpleNamespace( + context_grounding=Cg(), folders=FakeFolders({"Coded": "fk-c"}) + ) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + tool = uipath_deep_rag_tool( + "r", + index_name="invoices", + folder_path="Coded", + query_from_input="question", + folder_path_prefix_from_input="area", + file_extension="PDF", + ) + ctx = DurableToolContext( + call_id="c1", + runtime_id="rt", + tool_name="r", + agent_input={"question": "From the input", "area": "/finance/"}, + ) + value = await tool.start({"query": "from the model"}, ctx) + assert isinstance(value, WaitDeepRagRaw) + assert (value.deep_rag.id, value.index_folder_key) == ("dr-9", "fk-c") + assert value.index_folder_path is None + [kw] = starts + assert kw["prompt"] == "From the input" + assert kw["glob_pattern"] == "finance/*.pdf" + assert kw["citation_mode"].value == "Inline" + assert (kw["folder_key"], kw["folder_path"]) == ("fk-c", None) + assert tool.started_output(value) == { + "status": "started", + "deep_rag_id": "dr-9", + "deep_rag_status": "Queued", + } + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (_not_found, "Context grounding index 'invoices' was not found."), + (_ingesting, "is still being ingested"), + (lambda: enriched(400, message="bad glob"), "(HTTP 400): bad glob"), + ], +) +async def test_deep_rag_start_errors_are_durable_tool_errors( + monkeypatch, error, expected +): + from uipath_codex_sdk import ( + DurableToolContext, + UiPathDurableToolError, + uipath_deep_rag_tool, + ) + + class Cg: + async def start_deep_rag_async(self, **kwargs: Any) -> Any: + raise error() + + client = SimpleNamespace(context_grounding=Cg(), folders=FakeFolders()) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + tool = uipath_deep_rag_tool("r", index_name="invoices") + with pytest.raises(UiPathDurableToolError) as info: + await tool.start({"query": "q"}, DurableToolContext("c", "rt", "r")) + assert expected in str(info.value) + + +@pytest.mark.parametrize( + ("prefix", "ext", "glob"), + [ + (None, None, "**/*"), + (None, "pdf", "**/*.pdf"), + ("hr", None, "hr/*"), + ("/hr/2026/**", "TXT", "hr/2026/*.txt"), + ("/", None, "**/*"), + ], +) +def test_build_glob_pattern_matches_langchain(prefix, ext, glob): + from uipath_codex_sdk.context_tools import build_glob_pattern + + assert build_glob_pattern(prefix, ext) == glob + + +def test_from_resource_builds_a_deep_rag_tool(): + from uipath_codex_sdk import UiPathDeepRagTool + + tool = context_tool_from_resource( + _with(retrievalMode="DeepRAG", citationMode={"value": "Skip"}) + ) + assert isinstance(tool, UiPathDeepRagTool) + assert tool.citation_mode.value == "Skip" + assert tool.index_name == "company-policies" + assert tool.folder_path_prefix == "hr" + with pytest.raises(ValueError, match="needs 'settings.citationMode'"): + context_tool_from_resource(_with(retrievalMode="DeepRAG")) + + +async def test_rebuilt_deep_rag_trigger_reads_without_registering_again(monkeypatch): + import uipath.platform.resume_triggers._protocol as protocol + from uipath.core.triggers import UiPathResumeTriggerName + from uipath.platform.common import WaitDeepRagRaw + from uipath.platform.context_grounding import DeepRagCreationResponse + from uipath.platform.resume_triggers import UiPathResumeTriggerHandler + + from uipath_codex_sdk.runtime.runtime import _rebuilt_trigger + from uipath_codex_sdk.runtime.session_store import rebuild_suspend_value + + value = WaitDeepRagRaw( + deep_rag=DeepRagCreationResponse.model_validate( + {"id": "dr-1", "lastDeepRagStatus": "Queued", "createdDate": "x"} + ), + index_folder_key="fk", + ) + # The value survives the state store as its own type. + stored = rebuild_suspend_value( + "WaitDeepRagRaw", json.loads(value.model_dump_json()) + ) + assert isinstance(stored, WaitDeepRagRaw) + + class Cg: + async def retrieve_deep_rag_async(self, id: str, **kwargs: Any) -> Any: + assert id == "dr-1" + return _deep_rag() + + monkeypatch.setattr( + protocol, "UiPath", lambda: SimpleNamespace(context_grounding=Cg()) + ) + handler = UiPathResumeTriggerHandler() + + async def no_create(*args: Any, **kwargs: Any): + raise AssertionError("create_trigger would register an external trigger") + + monkeypatch.setattr(handler, "create_trigger", no_create) + trigger = await _rebuilt_trigger(handler, stored) + assert trigger is not None + assert trigger.trigger_name == UiPathResumeTriggerName.DEEP_RAG_RAW + assert (trigger.item_key, trigger.folder_key) == ("dr-1", "fk") + read = await handler.read_trigger(trigger) + assert read is not None + assert read.id == "dr-1" and read.content.text == "Owes $4,200 [1]." + assert await _rebuilt_trigger(handler, {"not": "a model"}) is None + + +def test_hold_span_outside_a_durable_start_ends_the_span(): + from opentelemetry.sdk.trace import TracerProvider + + from uipath_codex_sdk.runtime.telemetry import ( + collect_held_spans, + held_spans, + hold_span, + ) + + tracer = TracerProvider().get_tracer("t") + loose = tracer.start_span("loose") + assert hold_span(loose) is False and not loose.is_recording() + token = collect_held_spans() + held = tracer.start_span("held") + assert hold_span(held) is True and held.is_recording() + [record] = held_spans(token) + assert record["name"] == "held" + held.end() diff --git a/packages/uipath-codex-sdk/tests/test_debug.py b/packages/uipath-codex-sdk/tests/test_debug.py new file mode 100644 index 00000000..5b19feae --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_debug.py @@ -0,0 +1,240 @@ +"""Tests for what `uipath debug` needs from this runtime. + +The debug command wraps the runtime in ``UiPathDebugRuntime``. These tests +drive that real stack (factory → resumable runtime → bundled codex → mock +model, fake Orchestrator jobs) over a scripted bridge, so the contract is +checked rather than assumed. +""" + +# ruff: noqa: F811 +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any, Literal + +import pytest +from uipath.runtime import ( + UiPathRuntimeContext, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.debug import UiPathBreakpointResult, UiPathDebugRuntime +from uipath.runtime.events import UiPathRuntimeStateEvent + +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory + +from .contract.mock_responses import function_call_item, input_items, message_item + +# The durable suite's fixtures, shared by import (so F401 here, F811 file-wide). +from .durable.conftest import ( # noqa: F401 + _hermetic_env, + mock_model, + project, + upstream, + world, +) +from .durable.fakes import AddScript, FakeOrchestrator + +pytestmark = pytest.mark.contract + +RUNTIME_ID = "default" +WARNING = "Breakpoints are not supported by the Codex runtime" +RUNTIME_LOGGER = "uipath_codex_sdk.runtime.runtime" + +PLAIN_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent + +agent = UiPathCodexAgent(model="mock-model", prompt="Say hi to {name}.") +""" + + +class ShellScript: + """Mock model: run one shell command, then answer.""" + + CALL_ID = "call_shell0000000000000000" + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]]: + outputs = [ + it + for it in input_items(body) + if it.get("type") == "function_call_output" + and it.get("call_id") == self.CALL_ID + ] + if outputs: + return [message_item("FINAL done", idx)] + return [ + function_call_item(self.CALL_ID, "exec_command", {"cmd": "echo debug-ran"}) + ] + + +class ScriptedBridge: + """A debug bridge that never pauses and records what it is shown. + + ``on_suspended`` runs when the run suspends, so a test can finish the job + the debug runtime is about to poll. + """ + + def __init__( + self, + breakpoints: list[str] | Literal["*"] | None = None, + on_suspended: Any = None, + ) -> None: + self.log: list[str] = [] + self.state_events: list[UiPathRuntimeStateEvent] = [] + self.breakpoints = breakpoints if breakpoints is not None else [] + self.on_suspended = on_suspended + + async def connect(self) -> None: + self.log.append("connect") + + async def disconnect(self) -> None: + self.log.append("disconnect") + + async def emit_execution_started(self, **kwargs: Any) -> None: + self.log.append("started") + + async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None: + if state_event.node_name != "": + self.state_events.append(state_event) + + async def emit_breakpoint_hit(self, breakpoint_result: UiPathBreakpointResult): + self.log.append("breakpoint") + + async def emit_execution_suspended(self, runtime_result: UiPathRuntimeResult): + trigger = runtime_result.trigger + assert trigger is not None + self.log.append(f"suspended:{trigger.trigger_type.name}") + if self.on_suspended is not None: + self.on_suspended(runtime_result) + + async def emit_execution_resumed(self, resume_data: Any) -> None: + self.log.append("resumed") + + async def emit_execution_completed(self, runtime_result: UiPathRuntimeResult): + self.log.append(f"completed:{runtime_result.status.name}") + + async def emit_execution_error(self, error: str) -> None: + self.log.append(f"error:{error}") + + async def wait_for_resume(self) -> Any: + return None + + async def wait_for_terminate(self) -> None: + await asyncio.Event().wait() # never asked to quit + + def get_breakpoints(self) -> list[str] | Literal["*"]: + return self.breakpoints + + +async def _debug_run( + project: Path, bridge: ScriptedBridge, input: dict[str, Any] +) -> list[Any]: + context = UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file="state.db" + ) + factory = UiPathCodexRuntimeFactory(context) + runtime = UiPathDebugRuntime( + delegate=await factory.new_runtime("agent", RUNTIME_ID), + debug_bridge=bridge, + trigger_poll_interval=0.05, + ) + try: + return [event async for event in runtime.stream(input, UiPathStreamOptions())] + finally: + await runtime.dispose() + await factory.dispose() + + +def _warnings(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + return [r for r in caplog.records if WARNING in r.getMessage()] + + +@pytest.fixture +def plain_project(project: Path) -> Path: + (project / "main.py").write_text(PLAIN_MAIN) + return project + + +async def test_state_events_reach_the_debug_bridge(plain_project, upstream, caplog): + upstream.script = ShellScript() + bridge = ScriptedBridge() + with caplog.at_level(logging.WARNING, logger=RUNTIME_LOGGER): + events = await _debug_run(plain_project, bridge, {"name": "Ada"}) + + assert events[-1].status == UiPathRuntimeStatus.SUCCESSFUL + assert bridge.log == ["connect", "started", "completed:SUCCESSFUL", "disconnect"] + shell = [ + e + for e in bridge.state_events + if (e.metadata or {}).get("item_type") == "commandExecution" + ] + assert len(shell) == 1, [e.node_name for e in bridge.state_events] + assert shell[0].node_name == "tools" + # Whether the command itself ran depends on the host's sandbox support. + assert "echo debug-ran" in shell[0].payload["command"] + assert {e.node_name for e in bridge.state_events} <= {"agent", "tools"} + assert all(e.execution_id == RUNTIME_ID for e in bridge.state_events) + assert _warnings(caplog) == [] + + +async def test_breakpoints_are_reported_as_unsupported_once( + plain_project, upstream, caplog +): + bridge = ScriptedBridge(breakpoints="*") + with caplog.at_level(logging.WARNING, logger=RUNTIME_LOGGER): + events = await _debug_run(plain_project, bridge, {"name": "Ada"}) + + assert events[-1].status == UiPathRuntimeStatus.SUCCESSFUL + assert "breakpoint" not in bridge.log + assert len(_warnings(caplog)) == 1 + + +async def test_named_breakpoints_are_reported_too(plain_project, upstream, caplog): + bridge = ScriptedBridge(breakpoints=["tools"]) + with caplog.at_level(logging.WARNING, logger=RUNTIME_LOGGER): + events = await _debug_run(plain_project, bridge, {"name": "Ada"}) + + assert events[-1].status == UiPathRuntimeStatus.SUCCESSFUL + assert len(_warnings(caplog)) == 1 + + +async def test_a_durable_run_suspends_and_resumes_through_the_debug_stack( + project, upstream, world: FakeOrchestrator +): + """The loop `uipath debug` runs on a job trigger: suspend, poll, finish.""" + script = AddScript(n_calls=1) + upstream.script = script + + def finish_job(result: UiPathRuntimeResult) -> None: + assert result.trigger is not None and result.trigger.item_key is not None + world.finish(result.trigger.item_key, "Successful", {"sum": 42}) + + bridge = ScriptedBridge(on_suspended=finish_job) + events = await _debug_run(project, bridge, {}) + + results = [e for e in events if isinstance(e, UiPathRuntimeResult)] + assert [r.status for r in results] == [ + UiPathRuntimeStatus.SUSPENDED, + UiPathRuntimeStatus.SUCCESSFUL, + ] + assert bridge.log == [ + "connect", + "started", + "suspended:JOB", + "resumed", + "completed:SUCCESSFUL", + "disconnect", + ] + resumed = [e for e in bridge.state_events if (e.payload or {}).get("resumed")] + assert len(resumed) == 1 + assert resumed[0].node_name == "tools" + assert resumed[0].execution_id == RUNTIME_ID + assert resumed[0].payload["result"] == {"sum": 42} + output = results[-1].output + assert isinstance(output, dict) + final = output["output"] + assert final == "FINAL " + json.dumps({script.call_ids[0]: '{"sum": 42}'}) diff --git a/packages/uipath-codex-sdk/tests/test_deep_rag_attachments.py b/packages/uipath-codex-sdk/tests/test_deep_rag_attachments.py new file mode 100644 index 00000000..172eed04 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_deep_rag_attachments.py @@ -0,0 +1,337 @@ +"""DeepRAG on a job attachment: schema, resource builder, start and finish.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from uipath.platform.common import WaitDeepRagRaw + +import uipath_codex_sdk.context_tools as ct +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + AttachmentStore, + DurableToolContext, + UiPathAttachment, + UiPathDurableToolError, + UiPathDurableToolPending, + analyze_files_tool_from_resource, + context_tool_from_resource, + deep_rag_tool_from_resource, + uipath_deep_rag_attachments_tool, +) +from uipath_codex_sdk.attachments import attachment_paths, not_found_message + +DOC_ID = "cccccccc-0000-0000-0000-000000000001" +DOC = {"ID": DOC_ID, "FullName": "contract.pdf", "MimeType": "application/pdf"} + +#: Agent Builder's default resource (``type`` lowercase, as it writes it). +RESOURCE: dict[str, Any] = { + "$resourceType": "tool", + "type": "internal", + "name": "Deep RAG (v2)", + "description": "Research the attached document.", + "inputSchema": ct.deep_rag_attachments_input_schema(), + "outputSchema": {"type": "object", "properties": {}}, + "properties": { + "toolType": "deep-rag", + "settings": { + "contextType": "attachments", + "query": {"variant": "dynamic", "description": "What to research"}, + "folderPathPrefix": {}, + "citationMode": {"value": "Inline"}, + "fileExtension": {"value": "pdf"}, + }, + }, + "settings": {}, +} + + +class _Grounding: + def __init__(self, error: Exception | None = None) -> None: + self.calls: list[dict[str, Any]] = [] + self.error = error + + async def start_deep_rag_from_attachments_async(self, **kwargs: Any) -> Any: + from uipath.platform.context_grounding import DeepRagCreationResponse + + self.calls.append(kwargs) + if self.error is not None: + raise self.error + return DeepRagCreationResponse.model_validate( + { + "id": "dr-1", + "name": kwargs["name"], + "createdDate": "2026-10-21T00:00:00Z", + "lastDeepRagStatus": "Queued", + } + ) + + +class _Folders: + def __init__(self, key: str | None = "fk-looked-up") -> None: + self.key = key + self.lookups: list[str] = [] + + async def retrieve_key_async(self, *, folder_path: str) -> str | None: + self.lookups.append(folder_path) + return self.key + + def retrieve_key(self, **kwargs: Any) -> Any: + raise AssertionError("blocking folder lookup") + + +@pytest.fixture +def client(monkeypatch): + holder = SimpleNamespace(context_grounding=_Grounding(), folders=_Folders()) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: holder) + monkeypatch.setattr(ct, "_folder_keys", {}) + for var in ("UIPATH_FOLDER_KEY", "UIPATH_FOLDER_PATH"): + monkeypatch.delenv(var, raising=False) + return holder + + +def _ctx(agent_input: dict[str, Any] | None = None) -> DurableToolContext: + store = AttachmentStore([UiPathAttachment.model_validate(DOC)]) + return DurableToolContext("call_1", "rt", "DeepRAG", agent_input, store, "m") + + +# --- schema and builders --------------------------------------------------- + + +def test_default_schema_and_names(): + tool = uipath_deep_rag_attachments_tool("Deep RAG (v2)") + assert tool.name == "Deep_RAG_v2" and tool.display_name == "Deep_RAG_v2" + assert tool.tool_type == "Internal" and tool.kind == "internal" + schema = tool.input_schema + assert schema["required"] == ["attachment", "query"] + assert schema["properties"]["query"]["description"] == ( + ct.DEEP_RAG_ATTACHMENTS_QUERY_DESCRIPTION + ) + assert attachment_paths(schema) == [("attachment",)] + fixed = uipath_deep_rag_attachments_tool( + query="Summarize it.", attachment_from_input="{{document}}" + ) + # The attachment stays visible, optional, for when the input has none. + assert list(fixed.input_schema["properties"]) == ["attachment"] + assert fixed.input_schema["required"] == [] + assert ( + "Set from the agent input 'document'" + in (fixed.input_schema["properties"]["attachment"]["description"]) + ) + assert attachment_paths(fixed.input_schema) == [("attachment",)] + + +def test_from_resource_registry_default(): + tool = deep_rag_tool_from_resource(RESOURCE) + assert tool.name == "Deep_RAG_v2" + assert tool.description == "Research the attached document." + assert tool.citation_mode.value == "Inline" + assert tool.input_schema["properties"]["query"]["description"] == ( + "What to research" + ) + assert RESOURCE["inputSchema"]["required"] == ["attachment"] # not mutated + + +def test_from_resource_refusals(): + static_empty = json.loads(json.dumps(RESOURCE)) + static_empty["properties"]["settings"]["query"] = {"variant": "static"} + with pytest.raises(ValueError, match="make the query dynamic"): + deep_rag_tool_from_resource(static_empty) + no_citation = json.loads(json.dumps(RESOURCE)) + del no_citation["properties"]["settings"]["citationMode"] + with pytest.raises(ValueError, match="Not a valid internal tool resource"): + deep_rag_tool_from_resource(no_citation) + with pytest.raises(ValueError, match="disabled"): + deep_rag_tool_from_resource({**RESOURCE, "isEnabled": False}) + with pytest.raises(ValueError, match="internal tool"): + analyze_files_tool_from_resource(RESOURCE) # a deep-rag resource + with pytest.raises(ValueError, match="use an internal 'deep-rag' tool"): + context_tool_from_resource( + { + "name": "Files", + "description": "d", + "contextType": "attachments", + "indexName": "Files", + "settings": { + "resultCount": 3, + "retrievalMode": "DeepRAG", + "threshold": 0, + }, + } + ) + + +def test_from_resource_static_query_and_argument_properties(): + data = json.loads(json.dumps(RESOURCE)) + data["properties"]["settings"]["query"] = { + "variant": "static", + "value": "Summarize the termination clauses.", + } + data["argumentProperties"] = { + "attachment": { + "variant": "argument", + "argumentPath": "$.document", + "isSensitive": False, + } + } + tool = deep_rag_tool_from_resource(data) + assert tool.query == "Summarize the termination clauses." + assert list(tool.input_schema["properties"]) == ["attachment"] + assert tool.input_schema["required"] == [] + static = json.loads(json.dumps(data)) + static["argumentProperties"]["attachment"] = { + "variant": "static", + "value": DOC, + "isSensitive": False, + } + assert deep_rag_tool_from_resource(static).input_schema["properties"] == {} + + +async def test_from_resource_jsonpath_argument_property(client, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-job") + data = json.loads(json.dumps(RESOURCE)) + data["argumentProperties"] = { + "$['attachment']": { + "variant": "argument", + "argumentPath": "$['document']", + "isSensitive": False, + } + } + tool = deep_rag_tool_from_resource(data) + assert set(tool.fixed_args) == {"attachment"} + assert "attachment" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["query"] + await tool.start({"query": "Find the fees."}, _ctx({"document": DOC})) + [call] = client.context_grounding.calls + assert call["attachments"] == [DOC_ID] + + +# --- start / finish ---------------------------------------------------------- + + +async def test_start_passes_the_attachment_and_the_job_folder(client, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-job") + tool = uipath_deep_rag_attachments_tool(citation_mode="Skip") + args = tool.validate_args({"attachment": DOC, "query": "Find the fees."}) + value = await tool.start(args, _ctx()) + assert isinstance(value, WaitDeepRagRaw) + assert value.deep_rag.id == "dr-1" and value.index_folder_key == "fk-job" + [call] = client.context_grounding.calls + assert call["attachments"] == [DOC_ID] + assert call["prompt"] == "Find the fees." + assert call["citation_mode"].value == "Skip" + assert (call["folder_key"], call["folder_path"]) == ("fk-job", None) + assert args == {"attachment": DOC, "query": "Find the fees."} # not mutated + assert tool.started_output(value)["deep_rag_id"] == "dr-1" + + +async def test_folder_resolution(client, monkeypatch): + tool = uipath_deep_rag_attachments_tool() + args = {"attachment": DOC, "query": "q"} + with pytest.raises(UiPathDurableToolError, match="needs a folder"): + await tool.start(args, _ctx()) # a local run without a folder + assert client.context_grounding.calls == [] + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Shared/Docs") + await tool.start(args, _ctx()) + assert client.context_grounding.calls[-1]["folder_key"] == "fk-looked-up" + assert client.folders.lookups == ["Shared/Docs"] # async, never blocking + client.folders.key = None + explicit = uipath_deep_rag_attachments_tool(folder_path="Missing") + with pytest.raises(UiPathDurableToolError, match="Folder 'Missing'"): + await explicit.start(args, _ctx()) + + +async def test_start_errors(client, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-job") + from uipath.platform.errors import EnrichedException + + tool = uipath_deep_rag_attachments_tool() + with pytest.raises(UiPathDurableToolError, match="Query is required"): + await tool.start({"attachment": DOC, "query": " "}, _ctx()) + with pytest.raises(UiPathDurableToolError, match="Attachment is required"): + await tool.start({"attachment": None, "query": "q"}, _ctx()) + with pytest.raises(UiPathDurableToolError, match="Attachment ID is required"): + await tool.start({"attachment": {"FullName": "x"}, "query": "q"}, _ctx()) + with pytest.raises(ValueError, match="missing required argument"): + tool.validate_args({"attachment": DOC}) + import httpx + + request = httpx.Request("POST", "https://t/ecs_/v2/deeprag/create") + response = httpx.Response(400, json={"message": "No valid files"}, request=request) + client.context_grounding.error = EnrichedException( + httpx.HTTPStatusError("400", request=request, response=response) + ) + with pytest.raises(UiPathDurableToolError) as info: + await tool.start({"attachment": DOC, "query": "q"}, _ctx()) + assert str(info.value).startswith( + "Could not start DeepRAG on attachment 'contract.pdf' (HTTP 400)" + ) + + +async def test_attachment_from_the_agent_input(client, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-job") + tool = uipath_deep_rag_attachments_tool( + query="Summarize.", attachment_from_input="document" + ) + await tool.start(tool.validate_args({}), _ctx({"document": {"ID": DOC_ID}})) + assert client.context_grounding.calls[-1]["attachments"] == [DOC_ID] + unknown = "cccccccc-0000-0000-0000-00000000ffff" + with pytest.raises(UiPathDurableToolError) as info: + await tool.start({}, _ctx({"document": {"ID": unknown}})) + assert str(info.value) == not_found_message(unknown) + + +async def test_unresolved_attachment_argument(client, monkeypatch): + # langchain's StaticArgsHandler: an argument the agent input lacks keeps + # the model's value; a required one neither filled is a tool error. + monkeypatch.setenv("UIPATH_FOLDER_KEY", "fk-job") + tool = uipath_deep_rag_attachments_tool(attachment_from_input="document") + other = "cccccccc-0000-0000-0000-00000000ffff" + # resolved: the agent input wins over the model's attachment + await tool.start( + {"attachment": {"ID": other}, "query": "q"}, _ctx({"document": DOC}) + ) + assert client.context_grounding.calls[-1]["attachments"] == [DOC_ID] + # unresolved: the model's (host-resolved) attachment + await tool.start({"attachment": DOC, "query": "q"}, _ctx({})) + assert client.context_grounding.calls[-1]["attachments"] == [DOC_ID] + # a partial attachment is still matched to a known one + await tool.start({"attachment": {"ID": DOC_ID}, "query": "q"}, _ctx({})) + assert client.context_grounding.calls[-1]["attachments"] == [DOC_ID] + calls = len(client.context_grounding.calls) + with pytest.raises(UiPathDurableToolError) as info: + await tool.start(tool.validate_args({"query": "q"}), _ctx({})) + assert str(info.value) == ( + "Invalid arguments for tool 'DeepRAG': missing required argument(s): " + "attachment. The agent input has no value for them; fill them in and " + "call the tool again." + ) + assert len(client.context_grounding.calls) == calls + ctx = _ctx({"document": DOC}) + assert tool.span_args({"query": "q"}, ctx) == {"query": "q", "attachment": DOC} + assert tool.mock_args({"query": "q"}, ctx) == {"query": "q", "attachment": DOC} + with pytest.raises(UiPathDurableToolError, match="missing required"): + tool.mock_args({"query": "q"}, _ctx({})) + + +async def test_finish_maps_the_task(): + tool = uipath_deep_rag_attachments_tool() + done = { + "id": "dr-1", + "name": "t", + "createdDate": "2026-10-21T00:00:00Z", + "lastDeepRagStatus": "Successful", + "content": {"text": "Ends with 30 days notice [1].", "citations": []}, + } + assert json.loads(await tool.finish({}, done)) == { + "text": "Ends with 30 days notice [1].", + "citations": [], + "deepRagId": "dr-1", + } + failed = {**done, "lastDeepRagStatus": "Failed", "failureReason": "bad file"} + assert json.loads(await tool.finish({}, failed))["error"] == "bad file" + with pytest.raises(UiPathDurableToolPending): + await tool.finish({}, {**done, "lastDeepRagStatus": "InProgress"}) diff --git a/packages/uipath-codex-sdk/tests/test_escalation_tools.py b/packages/uipath-codex-sdk/tests/test_escalation_tools.py new file mode 100644 index 00000000..01ea56cd --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_escalation_tools.py @@ -0,0 +1,612 @@ +"""Action Center escalation tools: schemas, task creation, results, resources. + +No codex here: the tool is called directly, against a fake ``tasks`` service +(the end-to-end suspend/resume runs are in ``tests/durable``). +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic import BaseModel + +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + DurableToolContext, + UiPathDurableToolError, + UiPathDurableToolPending, + UiPathEscalationTool, + escalation_tool_from_resource, + uipath_escalation_tool, +) +from uipath_codex_sdk.escalation_tools import ( + _task_from, + filter_task_data, + resolve_recipient_config, + task_url, +) +from uipath_codex_sdk.runtime.errors import UiPathRunTerminatedError +from uipath_codex_sdk.tools import is_durable_tool, tool_type_of + +from .durable.fakes import FakeTasks + +CTX = DurableToolContext(call_id="c1", runtime_id="rt", tool_name="escalate_x") + +#: The low-code HITL example's escalation resource (uipath-agents-python +#: examples/HITL_1), trimmed of UI-only fields. +HITL_RESOURCE: dict[str, Any] = { + "$resourceType": "escalation", + "name": "color", + "description": "Ask color confirmation from the user", + "channels": [ + { + "name": "Channel", + "description": "Channel description", + "inputSchema": { + "type": "object", + "properties": { + "Content": {"type": "string"}, + "Comment": {"type": "string"}, + }, + }, + "outputSchema": { + "type": "object", + "properties": {"Comment": {"type": "string"}}, + }, + "outcomeMapping": {"approve": "continue", "reject": "end"}, + "recipients": [{"type": 1, "value": "user-1", "displayName": "Ana"}], + "type": "actionCenter", + "properties": { + "appName": "HITL App", + "appVersion": 1, + "folderName": "Shared/HITL", + "isActionableMessageEnabled": False, + }, + } + ], + "isAgentMemoryEnabled": False, + "escalationType": 0, +} + + +def _resource(**channel: Any) -> dict[str, Any]: + r = json.loads(json.dumps(HITL_RESOURCE)) + r["channels"][0].update(channel) + return r + + +@pytest.fixture +def fake(monkeypatch) -> FakeTasks: + tasks = FakeTasks() + client = SimpleNamespace(tasks=tasks, assets=None) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + return tasks + + +class Review(BaseModel): + amount: float + reason: str + + +@pytest.fixture +def tracer(monkeypatch): + """A recording tracer for the tool's own spans.""" + from opentelemetry.sdk.trace import TracerProvider + + t = TracerProvider().get_tracer("test") + monkeypatch.setattr("uipath_codex_sdk.runtime.telemetry.get_tracer", lambda: t) + return t + + +def _hold_spans(): + """Collect held spans as the tool host does around ``start``.""" + from uipath_codex_sdk.runtime.telemetry import collect_held_spans + + return collect_held_spans() + + +# -------------------------------------------------------------------------- +# the tool +# -------------------------------------------------------------------------- +def test_tool_shape(): + tool = uipath_escalation_tool( + "Expense Approval", + input_schema=Review, + static_args={"reason": "fixed"}, + outcome_mapping={"Reject": "end"}, + ) + assert isinstance(tool, UiPathEscalationTool) and is_durable_tool(tool) + assert tool.name == "escalate_Expense_Approval" + assert tool.display_name == "Expense Approval" + assert tool_type_of(tool, tool.kind) == "Escalation" + # Pre-filled fields are hidden from the model. + assert list(tool.input_schema["properties"]) == ["amount"] + assert tool.input_schema["required"] == ["amount"] + assert "Action Center" in tool.description + assert tool.validate_args({"amount": 3}) == {"amount": 3} + with pytest.raises(ValueError): + tool.validate_args({}) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"folder_path": "a", "folder_key": "k"}, "folder_path or folder_key"), + ({"title": "t", "title_from_input": "x"}, "title or title_from_input"), + ({"outcome_mapping": {"Reject": "stop"}}, "use 'continue' or 'end'"), + ({"assignee": "a@b.c", "recipient": object()}, "recipient or assignee"), + ], +) +def test_invalid_configurations(kwargs, match): + with pytest.raises(ValueError, match=match): + uipath_escalation_tool("App", **kwargs) + + +async def test_start_creates_then_assigns_and_returns_wait_escalation(fake, tracer): + from uipath.platform.common import WaitEscalation + + tool = uipath_escalation_tool( + "ExpenseApproval", + folder_path="Shared", + input_schema=Review, + title_from_input="request.title", + assignee="manager@example.com", + priority="High", + labels=["finance"], + args_from_input={"reason": "{{request.reason}}"}, + ) + ctx = DurableToolContext( + "c1", + "rt", + tool.name, + agent_input={"request": {"title": "Offsite", "reason": "Team event"}}, + ) + token = _hold_spans() + value = await tool.start({"amount": 1250}, ctx) + from uipath_codex_sdk.runtime.telemetry import held_spans + + [child] = held_spans(token) + assert isinstance(value, WaitEscalation) + [created] = fake.created + assert created["title"] == "Offsite" + assert created["data"] == {"amount": 1250.0, "reason": "Team event"} + assert (created["app_folder_path"], created["app_folder_key"]) == ("Shared", None) + assert (created["priority"], created["labels"]) == ("High", ["finance"]) + [assignment] = fake.assignments + assert assignment["json"]["taskAssignments"][0]["UserNameOrEmail"] == ( + "manager@example.com" + ) + assert value.action.key == created["key"] + assert (value.app_name, value.app_folder_path, value.app_folder_key) == ( + "ExpenseApproval", + "Shared", + None, + ) + assert child["output_attribute"] == "result" + assert child["output_keys"] == ["outcome", "output"] + assert child["attributes"]["assignedTo"] == "manager@example.com" + assert tool.started_output(value)["task_key"] == created["key"] + + +async def test_folder_key_is_sent_alone_unless_a_binding_moves_the_app(fake): + from uipath.platform.common import ResourceOverwritesContext + from uipath.platform.common._bindings import GenericResourceOverwrite + + tool = uipath_escalation_tool("App", folder_key="fk-1") + value = await tool.start({}, CTX) + assert (fake.created[0]["app_folder_path"], fake.created[0]["app_folder_key"]) == ( + None, + "fk-1", + ) + assert (value.app_folder_path, value.app_folder_key) == (None, "fk-1") + + tool2 = uipath_escalation_tool("App", folder_path="Shared") + + async def overwrites(): + return { + "app.App.Shared": GenericResourceOverwrite.model_validate( + {"resource_type": "app", "name": "ProdApp", "folderPath": "Prod"} + ) + } + + async with ResourceOverwritesContext(overwrites): + value2 = await tool2.start({}, CTX) + created = fake.created[1] + assert (created["app_name"], created["app_folder_path"]) == ("ProdApp", "Prod") + assert (value2.app_name, value2.app_folder_path) == ("ProdApp", "Prod") + + +async def test_create_failure_is_a_durable_tool_error(fake): + fake.fail_create = Exception("App 'X' was not found") + tool = uipath_escalation_tool("X") + with pytest.raises(UiPathDurableToolError, match="App 'X' was not found"): + await tool.start({}, CTX) + + +async def test_assignment_failure_keeps_the_task(fake, tracer): + fake.fail_assign = RuntimeError("no such user") + tool = uipath_escalation_tool("App", assignee="ghost@example.com") + token = _hold_spans() + value = await tool.start({}, CTX) + from uipath_codex_sdk.runtime.telemetry import held_spans + + [child] = held_spans(token) + assert value.action.key == fake.created[0]["key"] + assert child["attributes"]["assignmentError"] == "no such user" + + +async def test_finish_maps_every_outcome(): + tool = uipath_escalation_tool( + "App", + input_schema={"type": "object", "properties": {"q": {"type": "string"}}}, + outcome_mapping={"Reject": "end"}, + ) + task = { + "key": "k", + "id": 7, + "status": 2, + "action": "Approve", + "data": {"q": "in", "Answer": "yes"}, + "completedByUser": {"emailAddress": "r@example.com"}, + "isDeleted": False, + } + assert json.loads(await tool.finish({}, task)) == { + "status": "completed", + "outcome": "Approve", + "output": {"Answer": "yes"}, # no output schema: the non-input fields + "task_id": 7, + "assigned_to": "r@example.com", + } + with pytest.raises(UiPathRunTerminatedError) as info: + await tool.finish({}, {**task, "action": "Reject"}) + assert json.loads(info.value.result_text)["outcome"] == "Reject" + assert "directive end" in str(info.value.error_info.title) + with pytest.raises(UiPathRunTerminatedError, match="deleted"): + await tool.finish({}, {**task, "isDeleted": True}) + for status in (0, 1, "Pending"): + with pytest.raises(UiPathDurableToolPending): + await tool.finish({}, {**task, "status": status}) + # Typed answers (uipath run --resume '{...}'). + typed = json.loads(await tool.finish({}, {"action": "Approve", "data": {"x": 1}})) + assert (typed["outcome"], typed["output"]) == ("Approve", {"x": 1}) + form = json.loads(await tool.finish({}, {"Answer": "no", "status": "closed"})) + assert (form["outcome"], form["output"]) == ( + None, + {"Answer": "no", "status": "closed"}, + ) + + +def test_task_detection_is_strict(): + assert _task_from({"key": "k", "status": 2}).key == "k" + assert _task_from({"isDeleted": True}).is_deleted is True + # A form that happens to have a "status" or "key" field is form data. + assert _task_from({"status": "ok"}).data == {"status": "ok"} + assert _task_from({"key": "k"}).data == {"key": "k"} + assert _task_from("yes").data == {"value": "yes"} + + +def test_filter_task_data_matches_langchain(): + data = {"in": 1, "out": 2, "extra": 3} + inputs: dict[str, Any] = {"properties": {"in": {}}} + assert filter_task_data(data, inputs, None) == {"out": 2, "extra": 3} + assert filter_task_data(data, inputs, {"properties": {"out": {}}}) == {"out": 2} + + +def test_task_url(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + assert task_url(5) is None + monkeypatch.setenv("UIPATH_URL", "https://cloud.test/o/t/") + assert task_url(5) == "https://cloud.test/o/t/actions_/tasks/5" + + +# -------------------------------------------------------------------------- +# agent.json resources +# -------------------------------------------------------------------------- +def test_from_resource_maps_the_channel(): + tool = escalation_tool_from_resource(HITL_RESOURCE) + assert tool.name == "escalate_color" + assert (tool.app_name, tool.folder_path) == ("HITL App", "Shared/HITL") + assert tool.display_name == "HITL App" + assert tool.description.startswith("Ask color confirmation from the user") + assert tool.outcome_mapping == {"approve": "continue", "reject": "end"} + assert tool.title == "Escalation Task" + assert tool.output_json_schema == { + "type": "object", + "properties": {"Comment": {"type": "string"}}, + } + recipient = asyncio.run(tool.resolve_recipient({})) + assert recipient is not None + assert (recipient.type.value, recipient.value, recipient.display_name) == ( + "UserId", + "user-1", + "Ana", + ) + + +def test_from_resource_title_and_argument_properties(): + tokens = [ + {"type": "simpleText", "rawString": "Review "}, + {"type": "variable", "rawString": "input.topic"}, + ] + tool = escalation_tool_from_resource( + _resource( + taskTitleV2={"type": "textBuilder", "tokens": tokens}, + argumentProperties={ + "Content": { + "variant": "argument", + "argumentPath": "$.topic", + "isSensitive": False, + }, + "Comment": { + "variant": "textBuilder", + "isSensitive": False, + "tokens": [{"type": "simpleText", "rawString": "auto"}], + }, + }, + ) + ) + # The text builder is hidden; the argument stays visible but optional. + assert list(tool.input_schema["properties"]) == ["Content"] + assert ( + "Set from the agent input 'topic'" + in (tool.input_schema["properties"]["Content"]["description"]) + ) + assert tool.resolve_title({"topic": "colors"}) == "Review colors" + data = tool.build_data({}, {"topic": "colors"}) + assert data == {"Content": "colors", "Comment": "auto"} + dynamic = escalation_tool_from_resource( + _resource(taskTitleV2={"type": "dynamic", "argumentPath": "subject"}) + ) + assert dynamic.resolve_title({"subject": "From input"}) == "From input" + + +def test_from_resource_jsonpath_keys_and_array_builder(): + # LangChain's JSONPath keys are converted (``$['Content']`` → ``Content``), + # and an array builder collects its items. + tool = escalation_tool_from_resource( + _resource( + argumentProperties={ + "$['Content']": { + "variant": "argument", + "argumentPath": "$['topic']", + "isSensitive": False, + }, + "$['Tags']": {"variant": "arrayBuilder"}, + "$['Tags'][0]": { + "variant": "static", + "value": "agent", + "isSensitive": False, + }, + }, + ) + ) + assert set(tool.fixed_args) == {"Content", "Tags"} + assert "Content" in tool.input_schema["properties"] # an argument: visible + assert "Tags" not in tool.input_schema["properties"] + data = tool.build_data({"Comment": "c"}, {"topic": "colors"}) + assert data == {"Comment": "c", "Content": "colors", "Tags": ["agent"]} + ctx = DurableToolContext( + call_id="c1", runtime_id="rt", tool_name="t", agent_input={"topic": "colors"} + ) + assert tool.span_args({"Comment": "c"}, ctx) == data # the Tool call's input + + +def _argument_resource() -> dict[str, Any]: + """HITL with a required ``Content`` read from the agent input's ``topic``.""" + resource = _resource( + argumentProperties={ + "Content": { + "variant": "argument", + "argumentPath": "$.topic", + "isSensitive": False, + }, + "Comment": {"variant": "static", "value": "auto", "isSensitive": False}, + } + ) + resource["channels"][0]["inputSchema"]["required"] = ["Content", "Comment"] + return resource + + +def test_argument_property_is_optional_for_the_model(): + tool = escalation_tool_from_resource(_argument_resource()) + schema = tool.input_schema + assert list(schema["properties"]) == ["Content"] # the static one is hidden + assert schema["required"] == [] + assert schema["properties"]["Content"]["description"] == ( + "Set from the agent input 'topic' when it has a value; fill it only if " + "it does not." + ) + assert tool.validate_args({}) == {} + # resolved: the agent input wins over the model + assert tool.build_data({"Content": "model"}, {"topic": "colors"}) == { + "Content": "colors", + "Comment": "auto", + } + # unresolved: the model's value is kept (langchain's StaticArgsHandler) + assert tool.build_data({"Content": "model"}, {}) == { + "Content": "model", + "Comment": "auto", + } + # unresolved and not filled: a tool error asking the model to fill it + with pytest.raises(UiPathDurableToolError) as info: + tool.build_data({}, {"other": 1}) + assert str(info.value) == ( + "Invalid arguments for tool 'escalate_color': missing required " + "argument(s): Content. The agent input has no value for them; fill " + "them in and call the tool again." + ) + + +async def test_unresolved_required_argument_creates_no_task(fake): + tool = escalation_tool_from_resource(_argument_resource()) + ctx = DurableToolContext("c1", "rt", tool.name, {}) + with pytest.raises(UiPathDurableToolError, match="missing required argument"): + await tool.start({}, ctx) + assert fake.created == [] + await tool.start({"Content": "teal"}, ctx) + [created] = fake.created + assert created["data"] == {"Content": "teal", "Comment": "auto"} + + +def test_required_argument_under_every_array_element(): + # ``items[*].owner`` from the agent input: optional for the model, but + # an element still without one once merged is a tool error (langchain + # keeps it required in each item). + tool = uipath_escalation_tool( + "ExpenseApproval", + input_schema={ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sku": {"type": "string"}, + "owner": {"type": "string"}, + }, + "required": ["sku", "owner"], + }, + } + }, + "required": ["items"], + }, + args_from_input={"items[*].owner": "user.name"}, + ) + item = tool.input_schema["properties"]["items"]["items"] + assert item["required"] == ["sku"] + assert ( + "Set from the agent input 'user.name'" + in item["properties"]["owner"]["description"] + ) + two = {"items": [{"sku": "a"}, {"sku": "b", "owner": "Bo"}]} + assert tool.build_data(two, {"user": {"name": "Al"}}) == { + "items": [{"sku": "a", "owner": "Al"}, {"sku": "b", "owner": "Al"}] + } + with pytest.raises( + UiPathDurableToolError, match=r"argument\(s\): items\[\*\]\.owner\." + ): + tool.build_data(two, {}) + filled = {"items": [{"sku": "a", "owner": "Cy"}, {"sku": "b", "owner": "Bo"}]} + assert tool.build_data(filled, {}) == filled + assert tool.build_data({"items": []}, {}) == {"items": []} + + +def test_coded_args_from_input_with_a_pydantic_schema(): + tool = uipath_escalation_tool( + "ExpenseApproval", + input_schema=Review, + args_from_input={"reason": "{{request.reason}}"}, + ) + assert set(tool.input_schema["properties"]) == {"amount", "reason"} + assert tool.input_schema["required"] == ["amount"] + assert tool.build_data({"amount": 1, "reason": "model"}, {})["reason"] == "model" + assert ( + tool.build_data({"amount": 1, "reason": "model"}, {"request": {"reason": "in"}}) + )["reason"] == "in" + with pytest.raises(UiPathDurableToolError, match=r"argument\(s\): reason\."): + tool.build_data({"amount": 1}, {}) + + +@pytest.mark.parametrize( + ("change", "match"), + [ + ({"isEnabled": False}, "is disabled"), + ({"channels": []}, "has no channels"), + ], +) +def test_from_resource_refusals(change, match): + with pytest.raises(ValueError, match=match): + escalation_tool_from_resource({**HITL_RESOURCE, **change}) + + +def test_from_resource_refuses_quick_forms_and_missing_app(): + quick = json.loads(json.dumps(HITL_RESOURCE)) + quick["channels"][0]["type"] = "actionCenterQuickForm" + quick["channels"][0]["properties"] = {"schema": {"schemaId": "s"}} + with pytest.raises(ValueError, match="not supported yet"): + escalation_tool_from_resource(quick) + no_app = json.loads(json.dumps(HITL_RESOURCE)) + no_app["channels"][0]["properties"]["appName"] = None + with pytest.raises(ValueError, match="has no 'appName'"): + escalation_tool_from_resource(no_app) + + +async def test_recipient_kinds(monkeypatch): + from uipath.agent.models.agent import AgentEscalationChannel + + async def asset(name: str, folder_path: str | None = None): + return SimpleNamespace(value=f"{name}@example.com") + + client = SimpleNamespace(assets=SimpleNamespace(retrieve_async=asset)) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + + def recipient(raw: dict[str, Any]): + channel = AgentEscalationChannel.model_validate( + {**HITL_RESOURCE["channels"][0], "recipients": [raw]} + ) + return channel.recipients[0] + + cases = [ + ({"type": "UserEmail", "value": "a@b.c"}, ("UserEmail", "a@b.c")), + ({"type": "GroupName", "value": "Finance"}, ("GroupName", "Finance")), + ( + {"type": "AssetUserEmail", "assetName": "approver", "folderPath": "Shared"}, + ("UserEmail", "approver@example.com"), + ), + ({"type": "ArgumentEmail", "argumentName": "who"}, ("UserEmail", "x@y.z")), + ( + {"type": "Workload", "value": "g1", "displayName": "Team A"}, + ("Workload", "Team A"), + ), + ] + for raw, (kind, value) in cases: + got = await resolve_recipient_config(recipient(raw), {"who": "x@y.z"}) + assert got is not None + assert (got.type.value, got.value, got.display_name) == (kind, value, value) + with pytest.raises(ValueError, match="no value for 'who'"): + await resolve_recipient_config( + recipient({"type": "ArgumentEmail", "argumentName": "who"}), {} + ) + + +# -------------------------------------------------------------------------- +# runtime: suspend value round-trip and trigger rebuild +# -------------------------------------------------------------------------- +async def test_wait_escalation_survives_the_store_and_rebuilds_without_side_effects( + monkeypatch, +): + from uipath.core.triggers import UiPathResumeTriggerName, UiPathResumeTriggerType + from uipath.platform.action_center import Task + from uipath.platform.common import WaitEscalation + from uipath.platform.resume_triggers import UiPathResumeTriggerHandler + + from uipath_codex_sdk.runtime.runtime import _call_operation, _rebuilt_trigger + from uipath_codex_sdk.runtime.session_store import ( + PendingCall, + rebuild_suspend_value, + ) + + value = WaitEscalation( + action=Task.model_validate({"id": 9, "key": "k9", "status": 0}), + app_name="App", + app_folder_path="Shared", + ) + call = PendingCall( + call_id="c", tool="t", args={}, value=value, value_type="WaitEscalation" + ) + stored = PendingCall.from_stored(json.loads(json.dumps(call.to_stored()))) + assert stored is not None + rebuilt = rebuild_suspend_value(stored.value_type, stored.value) + assert isinstance(rebuilt, WaitEscalation) and rebuilt.action.key == "k9" + assert _call_operation(stored) == {"task_id": "9", "task_key": "k9"} + trigger = await _rebuilt_trigger(UiPathResumeTriggerHandler(), rebuilt) + assert trigger is not None + assert trigger.trigger_type == UiPathResumeTriggerType.TASK + assert trigger.trigger_name == UiPathResumeTriggerName.ESCALATION + assert (trigger.item_key, trigger.folder_path) == ("k9", "Shared") diff --git a/packages/uipath-codex-sdk/tests/test_factory.py b/packages/uipath-codex-sdk/tests/test_factory.py new file mode 100644 index 00000000..400bf901 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_factory.py @@ -0,0 +1,238 @@ +"""Factory wiring: state file, CODEX_HOME, resumable wrapping.""" + +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +import pytest +from aiohttp import ClientSession +from uipath.runtime import UiPathResumableRuntime, UiPathRuntimeContext + +from uipath_codex_sdk import RemoteMcpServer, StdioMcpServer +from uipath_codex_sdk.mcp import build_mcp_config_overrides +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.gateway import CodexGatewayShim +from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage +from uipath_codex_sdk.runtime.tool_host import DurableToolHost + +from ._typing import sqlite_storage + +MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool +agent = UiPathCodexAgent(model="m", prompt="hi", tools=[uipath_process_tool("p")]) +""" + +PLAIN_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent +agent = UiPathCodexAgent(model="m", prompt="hi") +""" + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch) -> Path: + (tmp_path / "codex.json").write_text('{"agents": {"agent": "main.py:agent"}}') + (tmp_path / "main.py").write_text(MAIN) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _seed(project: Path) -> tuple[Path, Path]: + rd = project / "__uipath" + (rd / "codex_home" / "sessions").mkdir(parents=True) + marker = rd / "codex_home" / "sessions" / "old.jsonl" + marker.write_text("{}\n") + db = rd / "state.db" + db.write_bytes(b"") + (rd / "state.db-wal").write_bytes(b"") + return db, marker + + +@pytest.mark.parametrize( + ("kwargs", "reset"), + [ + ({}, True), + ({"resume": True}, False), + ({"job_id": "job-1"}, False), + ({"keep_state_file": True}, False), + ], +) +async def test_state_reset_rule(project, kwargs, reset): + db, marker = _seed(project) + ctx = UiPathRuntimeContext(runtime_dir=str(project / "__uipath"), **kwargs) + factory = UiPathCodexRuntimeFactory(ctx) + try: + runtime = await factory.new_runtime("agent", "rt") + assert isinstance(runtime, UiPathResumableRuntime) + delegate = runtime.delegate + assert isinstance(delegate, UiPathCodexRuntime) + assert delegate._codex_home == str( + (project / "__uipath" / "codex_home").resolve() + ) + assert marker.exists() is (not reset) + assert (project / "__uipath" / "state.db-wal").exists() is (not reset) + storage = await factory.get_storage() + assert isinstance(storage, SqliteResumableStorage) + assert storage is runtime.storage is delegate.storage + assert await factory.get_storage() is storage + finally: + await factory.dispose() + + +async def test_agent_without_durable_tools_keeps_its_codex_home(project): + """No durable tools: no run-scoped CODEX_HOME, and nothing of it is wiped.""" + (project / "main.py").write_text(PLAIN_MAIN) + _, marker = _seed(project) + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext(runtime_dir=str(project / "__uipath")) + ) + try: + runtime = await factory.new_runtime("agent", "rt") + assert isinstance(runtime, UiPathResumableRuntime) + assert isinstance(runtime.delegate, UiPathCodexRuntime) + assert runtime.delegate._codex_home is None + assert runtime.delegate._codex_home_dir({}) is None + assert marker.exists() + finally: + await factory.dispose() + + +async def test_explicit_state_file_path_is_never_reset(project): + """langchain's rule: an explicit state_file_path is kept on a fresh run, + and so is the run-scoped CODEX_HOME next to it.""" + db, marker = _seed(project) + db.write_bytes(b"keep") + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / "__uipath"), state_file_path=str(db) + ) + ) + try: + assert factory._should_reset_state() is False + await factory.new_runtime("agent", "rt") + assert marker.exists() + assert (project / "__uipath" / "state.db-wal").exists() + finally: + await factory.dispose() + + +async def test_explicit_state_file_path(project): + path = project / "custom" / "s.db" + factory = UiPathCodexRuntimeFactory(UiPathRuntimeContext(state_file_path=str(path))) + try: + storage = await sqlite_storage(factory) + await storage.set_value("rt", "ns", "k", "v") + assert path.exists() + finally: + await factory.dispose() + + +def test_parallel_flag_override(): + overrides = build_mcp_config_overrides( + { + "a": RemoteMcpServer(url="http://x/mcp", supports_parallel_tool_calls=True), + "b": StdioMcpServer(command="python", supports_parallel_tool_calls=False), + "c": RemoteMcpServer(url="http://y/mcp"), + } + ) + assert "mcp_servers.a.supports_parallel_tool_calls=true" in overrides + assert "mcp_servers.b.supports_parallel_tool_calls=false" in overrides + assert not any(o.startswith("mcp_servers.c.supports_parallel") for o in overrides) + + +async def test_gateway_mounts_tool_host_with_its_own_token(tmp_path): + storage = SqliteResumableStorage(str(tmp_path / "s.db")) + from uipath_codex_sdk.runtime.session_store import CodexSessionStore + + host = DurableToolHost([], store=CodexSessionStore(storage, "rt"), runtime_id="rt") + shim = CodexGatewayShim(default_model="m", settings=object(), mcp_handler=host) # type: ignore[arg-type] + await shim.start() + try: + assert shim.mcp_url.endswith("/mcp") and shim.inflight == 0 + msg = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + async with ClientSession() as s: + async with s.post( + shim.mcp_url, + json=msg, + headers={"Authorization": f"Bearer {host.token}"}, + ) as r: + assert r.status == 200 + assert (await r.json())["result"] == {"tools": []} + # the gateway key does not open the tool host... + async with s.post( + shim.mcp_url, + json=msg, + headers={"Authorization": f"Bearer {shim.api_key}"}, + ) as r: + assert r.status == 401 + # ...and the tool host token does not open the model routes + async with s.get( + shim.base_url + "/models", + headers={"Authorization": f"Bearer {host.token}"}, + ) as r: + assert r.status == 401 + async with s.get( + shim.base_url + "/models", + headers={"Authorization": f"Bearer {shim.api_key}"}, + ) as r: + assert r.status == 200 + finally: + await shim.stop() + await storage.dispose() + + +async def test_schema_ids_are_deployable(project): + # A deployed job hands filePath back to `uipath run`; a path from the + # packing machine would not resolve there. + factory = UiPathCodexRuntimeFactory(UiPathRuntimeContext()) + try: + runtime = await factory.new_runtime("agent", "run-1") + schema = await runtime.get_schema() + assert schema.file_path == "agent" + UUID(schema.unique_id) # Orchestrator requires a GUID + finally: + await factory.dispose() + + +IMMEDIATE_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent, uipath_integration_tool +agent = UiPathCodexAgent(model="m", prompt="hi", tools=[ + uipath_integration_tool("web_search", connection_id="c", tool_path="/x") +]) +""" + + +async def test_agent_with_only_immediate_tools_keeps_its_codex_home(project): + (project / "main.py").write_text(IMMEDIATE_MAIN) + _, marker = _seed(project) + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext(runtime_dir=str(project / "__uipath")) + ) + try: + runtime = await factory.new_runtime("agent", "rt") + assert isinstance(runtime, UiPathResumableRuntime) + delegate = runtime.delegate + assert isinstance(delegate, UiPathCodexRuntime) + assert delegate._codex_home is None + durable, immediate = delegate._agent_tools() + assert durable == {} and list(immediate) == ["web_search"] + assert delegate._codex_home_dir(durable) is None + assert delegate._hermetic(durable) is False + assert marker.exists() + finally: + await factory.dispose() + + +async def test_reports_the_framework(project): + """Coded run telemetry and governance audit events tell frameworks apart.""" + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext(runtime_dir=str(project / "__uipath")) + ) + try: + settings = await factory.get_settings() + assert settings is not None + assert settings.agent_type == "uipath_coded" + assert settings.agent_framework == "codex_sdk" + finally: + await factory.dispose() diff --git a/packages/uipath-codex-sdk/tests/test_immediate_tools_runtime.py b/packages/uipath-codex-sdk/tests/test_immediate_tools_runtime.py new file mode 100644 index 00000000..045a1949 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_immediate_tools_runtime.py @@ -0,0 +1,115 @@ +"""Runtime configuration of immediate (non-durable) UiPath tools.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from uipath_codex_sdk import ( + RemoteMcpServer, + UiPathCodexAgent, + uipath_integration_tool, + uipath_process_tool, +) +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError +from uipath_codex_sdk.runtime.runtime import ( + DURABLE_TOOL_TIMEOUT_SEC, + UiPathCodexRuntime, +) +from uipath_codex_sdk.runtime.schema import get_agent_graph +from uipath_codex_sdk.runtime.tool_host import SERVER_NAME, DurableToolHost + + +def _is_tool(name: str = "web_search", timeout: float | None = 300.0): + return uipath_integration_tool( + name, connection_id="c", tool_path="/x", timeout_sec=timeout + ) + + +def _runtime(tools: list[Any]) -> UiPathCodexRuntime: + agent = UiPathCodexAgent(model="m", prompt="p", tools=tools) + return UiPathCodexRuntime( + agent=agent, runtime_id="rt", entrypoint="agent", file_path="/tmp/x/main.py" + ) + + +def test_tools_split_into_durable_and_immediate(): + rt = _runtime([uipath_process_tool("a_plus_b"), _is_tool()]) + durable, immediate = rt._agent_tools() + assert list(durable) == ["a_plus_b"] and list(immediate) == ["web_search"] + assert list(rt._durable_tools()) == ["a_plus_b"] + + +def test_duplicate_names_across_kinds_are_rejected(): + rt = _runtime([uipath_process_tool("a_plus_b"), _is_tool("a_plus_b")]) + with pytest.raises(UiPathCodexRuntimeError) as info: + rt._agent_tools() + assert "CONFIG_INVALID" in str(info.value.error_info.code) + assert "Two tools are named 'a_plus_b'" in info.value.error_info.detail + + +class Both: + name = "both" + description = "?" + input_schema: dict[str, Any] = {"type": "object"} + + def validate_args(self, args): + return args + + async def start(self, args, ctx): + return None + + async def finish(self, args, value): + return "" + + async def call(self, args, ctx): + return "" + + +class Neither: + name = "neither" + description = "?" + + +@pytest.mark.parametrize("tool", [Both(), Neither()], ids=["both", "neither"]) +def test_a_tool_must_be_exactly_one_kind(tool): + with pytest.raises(UiPathCodexRuntimeError) as info: + _runtime([tool])._agent_tools() + assert "CONFIG_INVALID" in str(info.value.error_info.code) + + +def test_immediate_only_server_gets_a_short_tool_timeout(): + rt = _runtime([_is_tool(timeout=45), _is_tool("other", timeout=None)]) + _, immediate = rt._agent_tools() + host = DurableToolHost(list(immediate.values()), store=None, runtime_id="rt") + shim = SimpleNamespace(mcp_url="http://127.0.0.1:1/mcp") + server = rt._mcp_servers(shim, host)[SERVER_NAME] # type: ignore[arg-type] + assert isinstance(server, RemoteMcpServer) + assert server.tool_timeout_sec == 330.0 # 300 (default) + margin + assert server.omit_tools_from == ["deferred", "code_mode"] + assert rt._codex_home_dir({}) is None and rt._hermetic({}) is False + + +def test_durable_server_keeps_the_day_long_timeout(): + rt = _runtime([uipath_process_tool("a_plus_b"), _is_tool()]) + durable, immediate = rt._agent_tools() + host = DurableToolHost( + [*durable.values(), *immediate.values()], + store=object(), # type: ignore[arg-type] + runtime_id="rt", + ) + shim = SimpleNamespace(mcp_url="http://127.0.0.1:1/mcp") + server = rt._mcp_servers(shim, host)[SERVER_NAME] # type: ignore[arg-type] + assert isinstance(server, RemoteMcpServer) + assert server.tool_timeout_sec == DURABLE_TOOL_TIMEOUT_SEC + + +def test_graph_lists_immediate_tools(): + agent = UiPathCodexAgent( + model="m", tools=[uipath_process_tool("a_plus_b"), _is_tool()] + ) + tools = next(n for n in get_agent_graph(agent).nodes if n.id == "tools") + assert tools.metadata is not None + assert tools.metadata["tool_names"][:2] == ["a_plus_b", "web_search"] diff --git a/packages/uipath-codex-sdk/tests/test_integration_tools.py b/packages/uipath-codex-sdk/tests/test_integration_tools.py new file mode 100644 index 00000000..19fc1529 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_integration_tools.py @@ -0,0 +1,875 @@ +"""Integration Service activity tools: mapping, schemas, invocation, errors. + +No codex here: the tool is called directly, against a fake ``connections`` +service or the real SDK ``ConnectionsService`` with its HTTP layer captured. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +import httpx +import pytest +from pydantic import BaseModel, Field + +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + ToolContext, + UiPathIntegrationTool, + UiPathTool, + UiPathToolError, + integration_tool_from_resource, + uipath_integration_tool, +) +from uipath_codex_sdk.integration_tools import ( + _set_path, + extract_top_level_field, + fold_enums, + remove_asterisk, +) +from uipath_codex_sdk.tools import is_durable_tool, is_immediate_tool + +from .durable.fakes import FakeConnections, enriched + +CONN = "11111111-aaaa-bbbb-cccc-000000000001" +CTX = ToolContext(call_id="call_1", runtime_id="rt", tool_name="web_search") + +#: The web_researcher example's agent.json resource (uipath-agents-python), +#: trimmed of UI-only fields, with the connection id replaced. +WEB_SEARCH_RESOURCE: dict[str, Any] = { + "$resourceType": "tool", + "name": "Web Search", + "description": "Web search executes a search of the public domain.", + "type": "integration", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "The search engine to use", + "enum": ["GoogleCustomSearch"], + "oneOf": [ + {"const": "GoogleCustomSearch", "title": "GoogleCustomSearch"} + ], + }, + "query": {"type": "string", "description": "The query"}, + "num": {"type": "integer", "description": "Number of results."}, + }, + "required": ["provider", "query"], + }, + "outputSchema": { + "type": "object", + "properties": { + "formattedResults": {"type": "string"}, + "results[*]": { + "type": "array", + "items": {"$ref": "#/definitions/results[*]"}, + }, + }, + "definitions": { + "results[*]": { + "type": "object", + "properties": {"title": {"type": "string"}, "url": {"type": "string"}}, + } + }, + }, + "settings": {"timeout": 45}, + "properties": { + "toolPath": "/v2/webSearch", + "objectName": "v2::webSearch", + "toolDisplayName": "Web Search", + "toolDescription": "Web search.", + "method": "POST", + "connection": { + "id": CONN, + "name": "UiPath GenAI Activities", + "elementInstanceId": 0, + "connector": {"key": "uipath-uipath-airdk", "name": "GenAI"}, + "folder": {"key": "f867f9d2-365d-40ec-bdcd-ea0df740e58c"}, + }, + "parameters": [ + { + "name": "provider", + "type": "string", + "fieldLocation": "body", + "fieldVariant": "static", + "value": "GoogleCustomSearch", + }, + { + "name": "query", + "type": "string", + "fieldLocation": "body", + "fieldVariant": "dynamic", + "value": "{{prompt}}", + }, + { + "name": "num", + "type": "integer", + "fieldLocation": "body", + "fieldVariant": "dynamic", + }, + ], + "bodyStructure": {"contentType": "json"}, + }, + "isEnabled": True, +} + + +def _resource(**props: Any) -> dict[str, Any]: + r = copy.deepcopy(WEB_SEARCH_RESOURCE) + r["properties"].update(props) + return r + + +def _info(tool: UiPathIntegrationTool) -> dict[str, list[str]]: + i = tool.activity_metadata.parameter_location_info + return { + "query": i.query_params, + "path": i.path_params, + "header": i.header_params, + "multipart": i.multipart_params, + "body": i.body_fields, + } + + +@pytest.fixture +def fake(monkeypatch) -> FakeConnections: + conns = FakeConnections() + client = type("Client", (), {"connections": conns})() + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + return conns + + +# -------------------------------------------------------------------------- +# 1. ActivityMetadata mapping (langchain's convert_to_activity_metadata cases) +# -------------------------------------------------------------------------- +def test_metadata_mapping_from_parameters(): + params = [ + {"name": "q", "fieldLocation": "query"}, + {"name": "id", "fieldLocation": "path"}, + {"name": "X-Trace", "fieldLocation": "header"}, + {"name": "meta.a.b", "fieldLocation": "body"}, + {"name": "meta.c", "fieldLocation": "body"}, + {"name": "attachments[*].name", "fieldLocation": "body"}, + {"name": "upload", "fieldLocation": "file"}, + {"name": "note", "fieldLocation": "multipart"}, + {"name": "body", "fieldLocation": "multipart"}, # the envelope marker + {"name": "weird", "fieldLocation": "somewhere"}, + ] + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/items/{id}", + method="GETBYID", + parameters=params, + body_structure={"contentType": "multipart", "jsonBodySection": "body"}, + ) + md = tool.activity_metadata + assert md.method_name == "GET" + assert md.object_path == "/items/{id}" + assert md.content_type == "multipart/form-data" + assert md.json_body_section == "body" + assert _info(tool) == { + "query": ["q"], + "path": ["id"], + "header": ["X-Trace"], + "multipart": ["upload", "note"], + "body": ["meta", "attachments", "weird"], + } + + +def test_json_content_type_by_default(): + tool = uipath_integration_tool("t", connection_id=CONN, tool_path="/x") + assert tool.activity_metadata.content_type == "application/json" + assert tool.activity_metadata.json_body_section is None + + +@pytest.mark.parametrize( + ("name", "top"), + [ + ("metadata.field.test", "metadata"), + ("attachments[*]", "attachments"), + ("attachments[0].filename", "attachments"), + ("simple", "simple"), + ], +) +def test_extract_top_level_field(name, top): + assert extract_top_level_field(name) == top + + +# -------------------------------------------------------------------------- +# 2. coded location inference +# -------------------------------------------------------------------------- +class SearchIn(BaseModel): + query: str = Field(description="What to search for.") + num: int | None = None + + +def test_post_schema_properties_go_to_the_body(): + tool = uipath_integration_tool( + "web search", + connection_id=CONN, + tool_path="/v2/webSearch", + input_schema=SearchIn, + static_args={"provider": "GoogleCustomSearch"}, + parameters=[ + { + "name": "X-Elements-Service-Profile", + "fieldLocation": "header", + "fieldVariant": "static", + "value": "high-capacity", + } + ], + ) + assert tool.name == "web_search" + assert tool.display_name == "web search" + assert _info(tool)["body"] == ["query", "num", "provider"] + assert _info(tool)["header"] == ["X-Elements-Service-Profile"] + assert set(tool.input_schema["properties"]) == {"query", "num"} + assert isinstance(tool, UiPathTool) + assert is_immediate_tool(tool) and not is_durable_tool(tool) + + +def test_get_schema_properties_go_to_the_query_and_placeholders_to_the_path(): + tool = uipath_integration_tool( + "user", + connection_id=CONN, + tool_path="/UsersByEmail/{usersByEmailId}", + method="GETBYID", + input_schema={ + "type": "object", + "properties": { + "usersByEmailId": {"type": "string"}, + "fields": {"type": "string"}, + }, + "required": ["usersByEmailId"], + }, + ) + assert tool.activity_metadata.method_name == "GET" + assert _info(tool)["path"] == ["usersByEmailId"] + assert _info(tool)["query"] == ["fields"] + assert _info(tool)["body"] == [] + + +def test_name_in_two_locations_is_rejected(): + with pytest.raises(ValueError, match="two locations"): + uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + parameters=[ + {"name": "a", "fieldLocation": "query"}, + {"name": "a", "fieldLocation": "header"}, + ], + ) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"tool_path": " "}, "tool_path"), + ({"connection_id": ""}, "connection_id"), + ({"method": "TRACE"}, "unsupported method"), + ], +) +def test_coded_constructor_checks(kwargs, match): + base = {"connection_id": CONN, "tool_path": "/x"} + with pytest.raises(ValueError, match=match): + uipath_integration_tool("t", **{**base, **kwargs}) + + +def test_schema_from_parameters_when_no_schema_is_given(): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + parameters=[ + {"name": "q", "type": "string", "fieldLocation": "query", "required": True}, + { + "name": "k", + "fieldLocation": "query", + "fieldVariant": "static", + "value": 1, + }, + ], + ) + assert tool.input_schema == { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + } + assert tool.static_args == {"k": 1} + + +# -------------------------------------------------------------------------- +# 3. static / argument parameters +# -------------------------------------------------------------------------- +def test_static_fields_are_hidden_and_override_the_model(): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + input_schema={ + "type": "object", + "properties": {"provider": {"type": "string"}, "query": {"type": "string"}}, + "required": ["provider", "query"], + }, + static_args={"provider": "Google"}, + ) + assert set(tool.input_schema["properties"]) == {"query"} + assert tool.input_schema["required"] == ["query"] + args = tool.validate_args({"query": "q", "provider": "Bing"}) + assert tool.build_payload(args, None) == {"query": "q", "provider": "Google"} + + +def test_pydantic_model_requiring_a_static_field_still_validates(): + class In(BaseModel): + provider: str + query: str + + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + input_schema=In, + static_args={"provider": "Google"}, + ) + assert "provider" not in tool.input_schema["properties"] + assert tool.validate_args({"query": "q"}) == {"provider": "Google", "query": "q"} + + +def test_nested_static_paths(): + obj: dict[str, Any] = {"attachments": [{"text": "a"}, {"text": "b"}]} + _set_path(obj, "attachments[*].actions.confirm", True) + _set_path(obj, "meta.source.app", "codex") + _set_path(obj, "absent[*].x", 1) # no list, nothing created + _set_path(obj, "items[1].id", 7) + assert obj == { + "attachments": [ + {"text": "a", "actions": {"confirm": True}}, + {"text": "b", "actions": {"confirm": True}}, + ], + "meta": {"source": {"app": "codex"}}, + "items": [{}, {"id": 7}], + } + + +def test_argument_parameters_resolve_from_the_agent_input(): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + input_schema={"type": "object", "properties": {"q": {"type": "string"}}}, + parameters=[ + { + "name": "channel", + "fieldLocation": "body", + "fieldVariant": "argument", + "value": "{{team.channel}}", + }, + { + "name": "lang", + "fieldLocation": "query", + "fieldVariant": "argument", + "value": "{{lang}}", + }, + ], + ) + assert set(tool.input_schema["properties"]) == {"q"} + payload = tool.build_payload({"q": "x"}, {"team": {"channel": "#ops"}}) + assert payload == {"q": "x", "channel": "#ops"} # lang missing: skipped + + +def test_malformed_argument_template(): + with pytest.raises(ValueError, match="malformed template"): + uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + parameters=[ + { + "name": "a", + "fieldLocation": "body", + "fieldVariant": "argument", + "value": "lang", + } + ], + ) + + +def test_unrouted_arguments_are_dropped_with_a_warning(caplog): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + input_schema={"type": "object", "properties": {"q": {"type": "string"}}}, + ) + assert tool.validate_args({"q": "x", "extra": 1}) == {"q": "x"} + assert "extra" in caplog.text + with pytest.raises(ValueError): + tool.validate_args({"q": 5}) + + +# -------------------------------------------------------------------------- +# 4. from an agent.json resource +# -------------------------------------------------------------------------- +def test_from_resource_web_search(): + tool = integration_tool_from_resource(WEB_SEARCH_RESOURCE) + assert tool.name == "Web_Search" + assert tool.display_name == "Web Search" + assert tool.connection_id == CONN + assert tool.connector_key == "uipath-uipath-airdk" + assert tool.object_name == "v2::webSearch" + assert tool.timeout_sec == 45 + assert tool.static_args == {"provider": "GoogleCustomSearch"} + # provider is static: hidden; the rest stays + assert set(tool.input_schema["properties"]) == {"query", "num"} + assert tool.input_schema["required"] == ["query"] + assert _info(tool)["body"] == ["provider", "query", "num"] + assert tool.activity_metadata.method_name == "POST" + # [*] stripped from the output schema; the description mentions it + out = tool.output_json_schema + assert out is not None + assert "results" in out["properties"] and "results" in out["definitions"] + assert out["properties"]["results"]["items"]["$ref"] == "#/definitions/results" + assert "Returns JSON matching" in tool.description + assert tool.span_attributes == { + "connector_key": "uipath-uipath-airdk", + "connector_name": "UiPath GenAI Activities", + "connection_id": CONN, + "object_name": "v2::webSearch", + "tool.display_name": "Web Search", + } + + +def test_fold_enums(): + schema = WEB_SEARCH_RESOURCE["inputSchema"] + folded = fold_enums(schema, ["provider", "query", "missing.path"]) + provider = folded["properties"]["provider"] + assert "enum" not in provider + assert provider["description"] == ( + "The search engine to use (Allowed value(s): GoogleCustomSearch.)" + ) + assert "enum" in schema["properties"]["provider"] # the input is not mutated + templated = fold_enums( + {"type": "object", "properties": {"x": {"type": "string", "enum": ["{{a}}"]}}}, + ["x"], + ) + assert templated["properties"]["x"] == {"type": "string"} + + +def test_remove_asterisk(): + fixed = remove_asterisk(WEB_SEARCH_RESOURCE["outputSchema"]) + assert list(fixed["properties"]) == ["formattedResults", "results"] + assert list(fixed["definitions"]) == ["results"] + + +@pytest.mark.parametrize( + ("change", "match"), + [ + ({"isEnabled": False}, "disabled"), + ({"properties": {"toolPath": ""}}, "toolPath"), + ( + {"properties": {"connection": {"id": None, "elementInstanceId": 0}}}, + "connection.id", + ), + ], +) +def test_from_resource_refuses(change, match): + r = copy.deepcopy(WEB_SEARCH_RESOURCE) + for key, value in change.items(): + if isinstance(value, dict): + r[key].update(value) + else: + r[key] = value + with pytest.raises(ValueError, match=match): + integration_tool_from_resource(r) + + +def test_from_resource_model_instance(): + from uipath.agent.models.agent import AgentIntegrationToolResourceConfig + + data = {**WEB_SEARCH_RESOURCE, "type": "Integration", "settings": None} + model = AgentIntegrationToolResourceConfig.model_validate(data) + tool = integration_tool_from_resource(model, tool_name="search", timeout_sec=9) + assert tool.name == "search" and tool.timeout_sec == 9 + + +# -------------------------------------------------------------------------- +# 5./8. against the real SDK request builder (and resource overwrites) +# -------------------------------------------------------------------------- +class CapturedSdk: + """A real ``ConnectionsService`` whose HTTP calls are captured.""" + + def __init__(self, folder_key: str = "fk-orig") -> None: + from uipath.platform.common._config import UiPathApiConfig + from uipath.platform.common._execution_context import UiPathExecutionContext + from uipath.platform.connections._connections_service import ( + ConnectionsService, + ) + + self.requests: list[dict[str, Any]] = [] + self.folders = {CONN: folder_key} + self.service = ConnectionsService( + UiPathApiConfig(base_url="https://cloud.test/org/tenant", secret="s"), + UiPathExecutionContext(), + folders_service=None, # type: ignore[arg-type] + ) + self.service.request_async = self._request # type: ignore[method-assign, assignment] + self.connections = self.service + + async def _request(self, method: str, url: Any, **kwargs: Any) -> httpx.Response: + url = str(url) + self.requests.append({"method": method, "url": url, **kwargs}) + request = httpx.Request(method, "https://cloud.test" + url) + if url.startswith("/connections_/api/v1/Connections/"): + key = url.rsplit("/", 1)[1] + body: Any = { + "id": key, + "name": "GenAI", + "elementInstanceId": 7, + "folder": {"key": self.folders.get(key, "fk-other")}, + } + else: + body = {"results": [{"title": "Ünïcode", "url": "https://u"}]} + return httpx.Response(200, json=body, request=request) + + +async def test_call_routes_through_the_real_request_builder(monkeypatch): + sdk = CapturedSdk() + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: sdk) + tool = uipath_integration_tool( + "web_search", + connection_id=CONN, + tool_path="/v2/{version}/webSearch", + input_schema={ + "type": "object", + "properties": { + "version": {"type": "string"}, + "query": {"type": "string"}, + "num": {"type": "integer"}, + }, + }, + parameters=[ + {"name": "num", "fieldLocation": "query"}, + { + "name": "X-Elements-Service-Profile", + "fieldLocation": "header", + "fieldVariant": "static", + "value": "high-capacity", + }, + ], + static_args={"provider": "GoogleCustomSearch"}, + ) + args = tool.validate_args({"version": "v9", "query": "codex", "num": 3}) + text = await tool.call(args, CTX) + assert json.loads(text) == {"results": [{"title": "Ünïcode", "url": "https://u"}]} + assert "Ünïcode" in text # not ASCII-escaped + retrieve, invoke = sdk.requests + assert retrieve["url"] == f"/connections_/api/v1/Connections/{CONN}" + assert invoke["method"] == "POST" + assert invoke["url"] == f"/elements_/v3/element/instances/{CONN}/v2/v9/webSearch" + assert invoke["json"] == {"query": "codex", "provider": "GoogleCustomSearch"} + assert invoke["params"] == {"num": "3"} + headers = invoke["headers"] + assert headers["X-Elements-Service-Profile"] == "high-capacity" + assert "fk-orig" in headers.values() + + +async def test_resource_overwrite_rebinds_the_connection(monkeypatch): + from uipath.platform.common import ResourceOverwritesContext + from uipath.platform.common._bindings import ConnectionResourceOverwrite + + sdk = CapturedSdk() + sdk.folders["new-conn"] = "fk-new" + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: sdk) + tool = integration_tool_from_resource(WEB_SEARCH_RESOURCE) + + async def overwrites(): + return { + f"connection.{CONN}": ConnectionResourceOverwrite.model_validate( + { + "resource_type": "connection", + "connectionId": "new-conn", + "folderKey": "fk-new", + } + ) + } + + async with ResourceOverwritesContext(overwrites): + await tool.call(tool.validate_args({"query": "q"}), CTX) + retrieve, invoke = sdk.requests + assert retrieve["url"] == "/connections_/api/v1/Connections/new-conn" + assert invoke["url"] == "/elements_/v3/element/instances/new-conn/v2/webSearch" + assert "fk-new" in invoke["headers"].values() + assert tool.connection_id == CONN # never pre-resolved + + +# -------------------------------------------------------------------------- +# 6./7./9. errors, results, mockable passthrough +# -------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + enriched(400, message="query is required"), + "Integration service returned an error for tool 'web_search': query is required", + ), + ( + enriched(422, code="INVALID_DATA", message="bad num"), + "Integration service returned an error for tool 'web_search': bad num", + ), + ( + enriched(422, code="OTHER", message="nope"), + "Integration service call for tool 'web_search' failed (HTTP 422): nope", + ), + ( + enriched(500, message="down"), + "Integration service call for tool 'web_search' failed (HTTP 500): down", + ), + ( + httpx.ReadTimeout("slow"), + "Integration service call for tool 'web_search' timed out. You may retry.", + ), + ( + ValueError("Unsupported content type: text/csv"), + "Tool 'web_search' is misconfigured: Unsupported content type: text/csv", + ), + ], +) +async def test_errors_become_model_facing_text(fake, error, expected): + fake.fail = error + tool = uipath_integration_tool("web_search", connection_id=CONN, tool_path="/x") + with pytest.raises(UiPathToolError) as info: + await tool.call({}, CTX) + assert str(info.value) == expected + + +async def test_other_exceptions_propagate(fake): + fake.fail = RuntimeError("kaput") + tool = uipath_integration_tool("web_search", connection_id=CONN, tool_path="/x") + with pytest.raises(RuntimeError): + await tool.call({}, CTX) + + +async def test_list_result_and_single_real_call(fake): + fake.responses["/users"] = [{"id": 1}, {"id": 2}] + tool = uipath_integration_tool( + "users", connection_id=CONN, tool_path="/users", method="GET" + ) + assert json.loads(await tool.call({}, CTX)) == [{"id": 1}, {"id": 2}] + # Outside an eval mocking context ``mockable`` (``runtime.mocking.wrap``) + # calls through exactly once. + assert len(fake.invocations) == 1 + inv = fake.invocations[0] + assert inv.connection_id == CONN + assert inv.activity_metadata.method_name == "GET" + + +# -------------------------------------------------------------------------- +# review follow-ups +# -------------------------------------------------------------------------- +class _AliasedIn(BaseModel): + filter: str = Field(alias="$filter") + api_key: str = Field(alias="X-Api-Key") + top: int | None = Field(None, alias="$top") + + +def test_aliased_fields_are_validated_and_sent_by_alias(caplog): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/items", + method="GET", + input_schema=_AliasedIn, + parameters=[{"name": "X-Api-Key", "fieldLocation": "header"}], + ) + info = tool.activity_metadata.parameter_location_info + assert info.header_params == ["X-Api-Key"] + assert set(info.query_params) == {"$filter", "$top"} + assert set(tool.input_schema["properties"]) == {"$filter", "X-Api-Key", "$top"} + args = tool.validate_args({"$filter": "a eq 1", "X-Api-Key": "k"}) + assert args == {"$filter": "a eq 1", "X-Api-Key": "k"} + assert "no parameter location" not in caplog.text + + +class _LocaleIn(BaseModel): + query: str + locale: str = Field(description="Result language.") + + +def test_required_argument_field_of_a_pydantic_model(): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + input_schema=_LocaleIn, + parameters=[ + { + "name": "locale", + "fieldLocation": "body", + "fieldVariant": "argument", + "value": "{{lang}}", + } + ], + ) + # Visible but optional, with a note; the agent input wins when it has it. + locale = tool.input_schema["properties"]["locale"] + assert locale["description"].startswith("Result language. (Set from the agent") + assert tool.input_schema["required"] == ["query"] + args = tool.validate_args({"query": "q"}) + assert args == {"query": "q"} + assert tool.build_payload(args, {"lang": "fr"}) == {"query": "q", "locale": "fr"} + # The agent input has no value: the model's is sent. + args = tool.validate_args({"query": "q", "locale": "de"}) + assert tool.build_payload(args, {}) == {"query": "q", "locale": "de"} + assert tool.build_payload(args, {"lang": "fr"})["locale"] == "fr" + + +def test_argument_parameter_from_resource_stays_visible_to_the_model(): + resource = copy.deepcopy(WEB_SEARCH_RESOURCE) + resource["properties"]["parameters"].append( + { + "name": "num", + "type": "integer", + "fieldLocation": "body", + "fieldVariant": "argument", + "value": "{{count}}", + } + ) + resource["inputSchema"]["required"].append("num") + tool = integration_tool_from_resource(resource) + assert "num" in tool.input_schema["properties"] + assert "num" not in tool.input_schema["required"] + assert ( + "agent input 'count'" in tool.input_schema["properties"]["num"]["description"] + ) + args = tool.validate_args({"query": "q", "num": 3}) + assert tool.build_payload(args, None)["num"] == 3 + assert tool.build_payload(args, {"count": 9})["num"] == 9 + + +@pytest.mark.parametrize( + ("start", "expected"), + [ + ({"items": [], "x": 1}, {"items": ["v"], "x": 1}), + ({"x": 1}, {"x": 1, "items": ["v"]}), + ({"items": ["a", "b"]}, {"items": ["v", "v"]}), + ], +) +def test_static_star_on_empty_or_missing_array(start, expected): + _set_path(start, "items[*]", "v") + assert start == expected + + +def test_fold_enums_unwraps_a_nullable_leaf(): + schema = { + "type": "object", + "properties": { + "color": { + "anyOf": [{"type": "string", "enum": ["red", "blue"]}, {"type": "null"}] + } + }, + } + folded = fold_enums(schema, ["color"]) + branch = folded["properties"]["color"]["anyOf"][0] + assert "enum" not in branch + assert branch["description"] == "Allowed value(s): red, blue." + + +class _Attachment(BaseModel): + title: str + text: str + + +class _PostIn(BaseModel): + attachment: _Attachment + + +def test_nested_static_fields_are_hidden_and_prefilled(): + tool = uipath_integration_tool( + "t", + connection_id=CONN, + tool_path="/x", + input_schema=_PostIn, + static_args={"attachment.title": "T"}, + ) + attachment = tool.input_schema["properties"]["attachment"] + assert set(attachment["properties"]) == {"text"} + assert attachment["required"] == ["text"] + args = tool.validate_args({"attachment": {"text": "body"}}) + assert tool.build_payload(args, None) == { + "attachment": {"title": "T", "text": "body"} + } + # A dict schema with a $ref'd nested static field. + dict_tool = uipath_integration_tool( + "d", + connection_id=CONN, + tool_path="/x", + input_schema=_PostIn.model_json_schema(), + static_args={"attachment.title": "T"}, + ) + nested = dict_tool.input_schema["properties"]["attachment"] + assert "title" not in nested["properties"] and nested["required"] == ["text"] + + +def test_timeout_none_means_default_and_non_positive_is_refused(): + tool = uipath_integration_tool( + "t", connection_id=CONN, tool_path="/x", timeout_sec=None + ) + assert tool.timeout_sec == 300.0 + with pytest.raises(ValueError, match="timeout_sec must be positive"): + uipath_integration_tool("t", connection_id=CONN, tool_path="/x", timeout_sec=0) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Get User (v2)", "Get_User_v2"), + ("Slack: Send message", "Slack_Send_message"), + ("Send Message", "Send_Message"), + ("Web Search", "Web_Search"), + ("!!!", "___"), + ], +) +def test_resource_tool_names_match_low_code(name, expected): + resource = copy.deepcopy(WEB_SEARCH_RESOURCE) + resource["name"] = name + assert integration_tool_from_resource(resource).name == expected + assert integration_tool_from_resource(resource, tool_name="my tool").name == ( + "my_tool" + ) + + +@pytest.mark.parametrize( + ("error", "level"), + [ + (enriched(400, message="query is required"), "WARNING"), + (enriched(422, code="INVALID_DATA", message="bad"), "WARNING"), + (httpx.ReadTimeout("slow"), "WARNING"), + (enriched(500, message="down"), "ERROR"), + ], +) +async def test_is_errors_are_logged(fake, caplog, error, level): + fake.fail = error + tool = uipath_integration_tool("web_search", connection_id=CONN, tool_path="/x") + with caplog.at_level("WARNING"), pytest.raises(UiPathToolError): + await tool.call({}, CTX) + records = [r for r in caplog.records if "web_search" in r.getMessage()] + assert records and records[-1].levelname == level + + +def test_optional_field_keeps_its_description_in_the_tool_schema(): + class In(BaseModel): + num: int | None = Field(None, description="Number of results.") + + tool = uipath_integration_tool( + "t", connection_id=CONN, tool_path="/x", input_schema=In + ) + assert tool.input_schema["properties"]["num"] == { + "description": "Number of results.", + "title": "Num", + "type": "integer", + } diff --git a/packages/uipath-codex-sdk/tests/test_ixp_tools.py b/packages/uipath-codex-sdk/tests/test_ixp_tools.py new file mode 100644 index 00000000..0e8489ad --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_ixp_tools.py @@ -0,0 +1,137 @@ +"""IXP extraction and Document Validation tools: builders, routing, finish.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from uipath_codex_sdk import ( + UiPathDocumentValidationTool, + UiPathDurableToolPending, + escalation_tool_from_resource, + ixp_extraction_tool_from_resource, + uipath_document_validation_tool, + uipath_ixp_extraction_tool, + vs_escalation_tool_from_resource, +) +from uipath_codex_sdk.attachments import attachment_paths +from uipath_codex_sdk.runtime.errors import UiPathRunTerminatedError +from uipath_codex_sdk.tool_results import ToolResultStore + +from .test_escalation_tools import HITL_RESOURCE + +IXP_RESOURCE: dict[str, Any] = { + "$resourceType": "tool", + "type": "ixp", + "name": "Invoice Extraction", + "description": "Extracts invoice fields.", + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "properties": {"projectName": "Invoices", "versionTag": "live"}, +} + + +def _vs_resource(**props: Any) -> dict[str, Any]: + resource = json.loads(json.dumps(HITL_RESOURCE)) + resource["name"] = "Validate Invoice" + resource["escalationType"] = 1 + resource["vsEscalationProperties"] = { + "ixpToolId": "Invoice Extraction", + "storageBucketName": "invoices-bucket", + "storageBucketFolderPath": "incoming", + **props, + } + return resource + + +def test_extraction_factory_and_resource(): + tool = uipath_ixp_extraction_tool( + "Invoice Extraction", project_name="Invoices", version_tag="live" + ) + assert tool.name == "Invoice_Extraction" and tool.result_key == "Invoice Extraction" + assert tool.tool_type == "IxpExtraction" + assert attachment_paths(tool.input_schema) == [("attachment",)] + built = ixp_extraction_tool_from_resource(IXP_RESOURCE) + assert (built.project_name, built.version_tag) == ("Invoices", "live") + # An input schema without an attachment field falls back to langchain's. + assert attachment_paths(built.input_schema) == [("attachment",)] + no_tag = json.loads(json.dumps(IXP_RESOURCE)) + no_tag["properties"]["versionTag"] = None + with pytest.raises(ValueError, match="Model is required"): + ixp_extraction_tool_from_resource(no_tag) + with pytest.raises(ValueError, match="disabled"): + ixp_extraction_tool_from_resource({**IXP_RESOURCE, "isEnabled": False}) + + +def test_validation_resource_and_routing(): + tool = vs_escalation_tool_from_resource(_vs_resource()) + assert isinstance(tool, UiPathDocumentValidationTool) + assert tool.extraction_tool == "Invoice Extraction" + assert tool.storage_bucket_name == "invoices-bucket" + # The bucket's Orchestrator folder: where DU looks the bucket up. + assert tool.action_folder == "incoming" + assert tool.storage_bucket_directory_path is None + assert tool.input_schema["properties"] == {} + assert tool.tool_type == "Escalation" + # escalationType 1 is routed by the general helper, as langchain dispatches. + routed = escalation_tool_from_resource(_vs_resource()) + assert isinstance(routed, UiPathDocumentValidationTool) + with pytest.raises(ValueError, match="IXP tool is required"): + vs_escalation_tool_from_resource(_vs_resource(ixpToolId="")) + with pytest.raises(ValueError, match="Storage bucket is required"): + vs_escalation_tool_from_resource(_vs_resource(storageBucketName=None)) + + +def test_validation_factory_takes_the_extraction_tool(): + extract = uipath_ixp_extraction_tool( + "Invoice Extraction", project_name="Invoices", version_tag="live" + ) + tool = uipath_document_validation_tool("Validate", extraction_tool=extract) + assert tool.extraction_tool == "Invoice Extraction" + assert "'Invoice Extraction'" in tool.description + assert tool.resolve_title(None) == "VS Escalation Task" + + +def _action(status: str, data: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "actionData": {"status": status, "data": data or {}}, + "actionStatus": status, + "projectId": "p", + "projectType": "IXP", + "tag": "live", + "operationId": "val-1", + "extractorId": None, + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "dataProjection": [{"fieldGroupName": "Invoice", "fieldValues": []}], + } + + +async def test_validation_finish(): + tool = uipath_document_validation_tool("Validate", extraction_tool="X") + assert json.loads(await tool.finish({}, _action("Completed"))) == { + "data": [{"fieldGroupName": "Invoice", "fieldValues": []}] + } + with pytest.raises(UiPathDurableToolPending): + await tool.finish({}, _action("Unassigned")) + with pytest.raises(UiPathRunTerminatedError, match="Not an invoice"): + await tool.finish( + {}, + _action( + "Completed", {"documentRejectionDetails": {"reason": "Not an invoice"}} + ), + ) + with pytest.raises(UiPathRunTerminatedError, match="deleted"): + await tool.finish({}, _action("Deleted")) + + +def test_result_store_round_trip(): + store = ToolResultStore() + store.mark_pending("Invoice Extraction") + assert store.is_pending("Invoice Extraction") and store.changed + again = ToolResultStore.from_json(json.loads(json.dumps(store.to_json()))) + assert again.is_pending("Invoice Extraction") + again.put("Invoice Extraction", {"projectId": "p"}) + assert not again.is_pending("Invoice Extraction") + assert again.get("Invoice Extraction") == {"projectId": "p"} diff --git a/packages/uipath-codex-sdk/tests/test_mocking.py b/packages/uipath-codex-sdk/tests/test_mocking.py new file mode 100644 index 00000000..6dcbeb26 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_mocking.py @@ -0,0 +1,1229 @@ +"""Eval tool mocking: the simulation gate, mock specs and the host's mocked path. + +Real mockers: ``set_execution_context`` builds the LLM, mockito or +simulated-component mocker, as ``UiPathMockRuntime`` does in ``uipath eval``. +The LLM mocker's structured-output call is patched, and the platform client +raises, so a mocked call provably never reaches the tenant. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import contextvars +import json +from typing import Any + +import pytest +from aiohttp import ClientSession, ClientTimeout, web +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from pydantic import BaseModel +from uipath.eval._execution_context import ExecutionSpanCollector +from uipath.eval.mocks import ComponentSimulationConfig, ExampleCall +from uipath.eval.mocks._mock_runtime import ( + clear_execution_context, + set_execution_context, +) +from uipath.eval.mocks._types import ( + LLMMockingStrategy, + MockingContext, + MockitoMockingStrategy, + ToolSimulation, + UnknownMockingStrategy, +) + +import uipath_codex_sdk.attachment_tools as at +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + AttachmentStore, + ToolContext, + UiPathAttachment, + analyze_files_tool_from_resource, + batch_transform_tool_from_resource, + context_tool_from_resource, + deep_rag_tool_from_resource, + escalation_tool_from_resource, + integration_tool_from_resource, + ixp_extraction_tool_from_resource, + process_tool_from_resource, + uipath_agent_tool, + uipath_analyze_files_tool, + uipath_context_tool, + uipath_deep_rag_attachments_tool, + uipath_escalation_tool, + uipath_process_tool, + vs_escalation_tool_from_resource, +) +from uipath_codex_sdk.batch_transform_tools import BATCH_TRANSFORM_OUTPUT_SCHEMA +from uipath_codex_sdk.runtime import telemetry +from uipath_codex_sdk.runtime import tool_host as host_mod +from uipath_codex_sdk.runtime.errors import UiPathCodexErrorCode +from uipath_codex_sdk.runtime.mocking import ( + MOCKED_ANNOTATION_KEY, + MockSpec, + is_simulated, + mock_spec_of, +) +from uipath_codex_sdk.runtime.session_store import CodexSessionStore +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage +from uipath_codex_sdk.runtime.tool_host import DurableToolHost + +from .durable.fakes import FakeDocuments +from .test_analyze_files import PNG +from .test_analyze_files import RESOURCE as ANALYZE_RESOURCE +from .test_batch_transform_attachments import CSV +from .test_batch_transform_attachments import RESOURCE as BATCH_RESOURCE +from .test_context_tools import POLICY_RESOURCE, _with +from .test_deep_rag_attachments import RESOURCE as DEEP_RAG_RESOURCE +from .test_escalation_tools import HITL_RESOURCE +from .test_integration_tools import WEB_SEARCH_RESOURCE +from .test_ixp_tools import IXP_RESOURCE, _vs_resource +from .test_tool_host import FakeTool + +DOC_ID = "dddddddd-0000-0000-0000-000000000001" +DOC = {"ID": DOC_ID, "FullName": "invoice.pdf", "MimeType": "application/pdf"} +RESULT_ID = "eeeeeeee-0000-0000-0000-000000000001" + + +class AddIn(BaseModel): + a: int + b: int + + +class AddOut(BaseModel): + sum: int + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- +def llm(*names: str) -> LLMMockingStrategy: + return LLMMockingStrategy( + prompt="Simulate the tool.", + tools_to_simulate=[ToolSimulation(name=n) for n in names], + ) + + +def mockito(*behaviors: dict[str, Any]) -> MockitoMockingStrategy: + return MockitoMockingStrategy.model_validate( + {"type": "mockito", "config": list(behaviors)} + ) + + +def returns(function: str, *values: Any, kwargs: dict | None = None) -> dict: + behavior: dict[str, Any] = { + "function": function, + "then": [{"type": "return", "value": v} for v in values], + } + if kwargs is not None: + behavior["arguments"] = {"kwargs": kwargs} + return behavior + + +@contextlib.contextmanager +def simulating(strategy: Any = None, components: list[Any] | None = None): + """The mocking ContextVars ``UiPathMockRuntime`` sets around an execution.""" + set_execution_context( + MockingContext(strategy=strategy, components=components, name="case"), + ExecutionSpanCollector(), + "exec-1", + ) + try: + yield + finally: + clear_execution_context() + + +def extraction(total: str = "4200") -> dict[str, Any]: + """An ``ExtractionResponseIXP`` as the mocker returns it (aliases).""" + return { + "extractionResult": { + "DocumentId": "doc-1", + "ResultsVersion": 1, + "ResultsDocument": {"Fields": []}, + }, + "projectId": "p1", + "projectType": "IXP", + "tag": "live", + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "dataProjection": FakeDocuments.projection(total), + } + + +@pytest.fixture(autouse=True) +def no_platform(monkeypatch): + """Any platform call fails the test.""" + + def refuse(*_a: Any, **_k: Any) -> Any: + raise AssertionError("the platform was called") + + monkeypatch.setattr(tools_mod, "_uipath_client", refuse) + monkeypatch.setattr(at, "_uipath_client", refuse) + monkeypatch.setattr(at, "_gateway_client", refuse) + + +@pytest.fixture +def llm_answers(monkeypatch): + """The LLM mocker's structured-output call, answered by tool name.""" + import uipath.eval.mocks._llm_mocker as llm_mocker + + answers: dict[str, Any] = {} + calls: list[dict[str, Any]] = [] + + async def generate(llm_service, messages, *, schema, description, **kw): + calls.append({"schema": schema, "prompt": messages[0]["content"]}) + name = description.split("'")[1] + return answers[name] + + monkeypatch.setattr(llm_mocker, "generate_structured_output", generate) + monkeypatch.setattr( + llm_mocker, + "UiPath", + lambda: type("U", (), {"_config": None, "_execution_context": None})(), + ) + monkeypatch.setattr(llm_mocker, "UiPathLlmChatService", lambda *a, **k: object()) + answers["__calls__"] = calls + return answers + + +@pytest.fixture +def spans(monkeypatch): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer("t") + monkeypatch.setattr(telemetry, "get_tracer", lambda: tracer) + exporter.tracer = tracer # type: ignore[attr-defined] + return exporter + + +def _by_name(exporter: InMemorySpanExporter) -> dict[str, Any]: + return {s.name: s for s in exporter.get_finished_spans()} + + +class Served: + def __init__(self, host: DurableToolHost, url: str, store: CodexSessionStore): + self.host, self.url, self.store = host, url, store + self.probes: list[str] = [] + self._id = 0 + + async def call( + self, name: str, args: dict[str, Any], call_id: str | None = "call_1" + ) -> dict[str, Any]: + self._id += 1 + params: dict[str, Any] = {"name": name, "arguments": args} + if call_id is not None: + params["_meta"] = {"callId": call_id} + msg = { + "jsonrpc": "2.0", + "id": self._id, + "method": "tools/call", + "params": params, + } + headers = {"Authorization": f"Bearer {self.host.token}"} + async with ClientSession(timeout=ClientTimeout(total=10)) as s: + async with s.post(self.url, json=msg, headers=headers) as r: + return (await r.json())["result"] + + +@pytest.fixture +async def serve(tmp_path, monkeypatch): + """A tool host on a local port, built in the current (mocking) context.""" + cleanups: list[Any] = [] + pushed: list[Any] = [] + monkeypatch.setattr(host_mod, "push_running", lambda span: pushed.append(span)) + + async def make( + tools: list[Any], + *, + tracer: Any = None, + attachments: list[dict[str, Any]] = (), # type: ignore[assignment] + agent_input: dict[str, Any] | None = None, + ) -> Served: + storage = SqliteResumableStorage(str(tmp_path / f"s{len(cleanups)}.db")) + store = CodexSessionStore(storage, "rt") + host = DurableToolHost( + tools, + store=store, + runtime_id="rt", + context=contextvars.copy_context(), + tracer=tracer, + agent_input=agent_input, + attachments=AttachmentStore( + [UiPathAttachment.model_validate(a) for a in attachments] + ), + ) + app = web.Application() + app.router.add_route("*", "/mcp", host.handle) + runner = web.AppRunner(app, shutdown_timeout=1) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + served = Served(host, f"http://127.0.0.1:{port}/mcp", store) + + async def probe(call_id: str) -> bool: + served.probes.append(call_id) + return True + + host.bind(probe) + cleanups.append((host, runner, storage)) + return served + + make.pushed = pushed # type: ignore[attr-defined] + yield make + for host, runner, storage in cleanups: + await host.release_all() + await runner.cleanup() + await storage.dispose() + + +def _text(result: dict[str, Any]) -> str: + return result["content"][0]["text"] + + +# -------------------------------------------------------------------------- +# 1. the gate: the mockers' own name matching +# -------------------------------------------------------------------------- +def test_is_simulated_without_a_context(): + assert is_simulated("a_plus_b") is False + + +@pytest.mark.parametrize( + ("strategy", "components", "name", "expected"), + [ + (llm("Get User"), None, "Get User", True), + # The LLM mocker matches exactly: is_tool_simulated's "_" → " " would + # let this through and the mocker would then refuse it. + (llm("Get User"), None, "Get_User", False), + (mockito(returns("a_plus_b", 1)), None, "a_plus_b", True), + (mockito(returns("a_plus_b", 1)), None, "a plus b", False), + (mockito(returns("other", 1)), None, "a_plus_b", False), + (None, [ComponentSimulationConfig(component_id="Get_User")], "Get User", True), + (None, [ComponentSimulationConfig(component_id="Get User")], "Get_User", True), + (None, [ComponentSimulationConfig(component_id="Get User")], "Get User", True), + (None, [ComponentSimulationConfig(component_id="Get User")], "Other", False), + ], +) +def test_is_simulated_matrix(strategy, components, name, expected): + with simulating(strategy, components): + assert is_simulated(name) is expected + + +def test_is_simulated_is_false_when_no_mocker_could_be_built(): + with simulating(UnknownMockingStrategy(type="weird")): + assert is_simulated("a_plus_b") is False + + +# -------------------------------------------------------------------------- +# 2. mock specs: LangChain's names, schemas and example calls +# -------------------------------------------------------------------------- +EXAMPLE = {"id": "ex1", "input": '{"a": 1}', "output": '{"sum": 1}'} + + +def _escalation_with_examples() -> dict[str, Any]: + r = json.loads(json.dumps(HITL_RESOURCE)) + r["channels"][0]["properties"]["exampleCalls"] = [EXAMPLE] + return r + + +def _ixp_with_examples() -> dict[str, Any]: + r = json.loads(json.dumps(IXP_RESOURCE)) + r["properties"]["exampleCalls"] = [EXAMPLE] + return r + + +def test_process_and_agent_specs(): + tool = uipath_process_tool( + "Add Numbers", + tool_name="add", + input_schema=AddIn, + output_schema=AddOut, + example_calls=[ExampleCall.model_validate(EXAMPLE)], + ) + spec = tool.mock_spec + assert spec.name == "Add Numbers" # the process name, not the tool name + assert spec.output_schema["properties"] == { + "sum": {"title": "Sum", "type": "integer"} + } + assert [e.id for e in spec.example_calls] == ["ex1"] + assert spec.input_schema == tool.input_schema + bare = uipath_agent_tool("Helper") + assert bare.mock_spec.output_schema == {"type": "object", "properties": {}} + assert bare.mock_spec.example_calls == [] + + +def test_escalation_spec_is_langchains(): + tool = escalation_tool_from_resource(_escalation_with_examples()) + spec = tool.mock_spec + assert tool.name == "escalate_color" and spec.name == "escalate_color" + assert spec.output_schema["required"] == ["action", "data"] + props = spec.output_schema["properties"] + assert props["action"]["enum"] == ["approve", "reject"] + assert props["data"]["properties"] == {"Comment": {"type": "string"}} + assert [e.id for e in spec.example_calls] == ["ex1"] + coded = uipath_escalation_tool("ExpenseApproval") + assert coded.mock_spec.name == "escalate_expenseapproval" + assert coded.mock_spec.output_schema["properties"]["data"] == {"type": "object"} + + +def test_ixp_and_validation_specs(): + ixp = ixp_extraction_tool_from_resource(_ixp_with_examples()) + assert ixp.name == "Invoice_Extraction" + spec = ixp.mock_spec + assert spec.name == "Invoice Extraction" + assert "dataProjection" in spec.output_schema["properties"] + assert [e.id for e in spec.example_calls] == ["ex1"] + vs = vs_escalation_tool_from_resource(_vs_resource()) + vspec = vs.mock_spec + assert vspec.name == "Validate Invoice" and vspec.example_calls == [] + data = vspec.output_schema["properties"]["data"] + assert {b.get("type") for b in data["anyOf"]} == {"array", "null"} + + +def test_context_and_deep_rag_specs(): + search = context_tool_from_resource(POLICY_RESOURCE) + assert search.mock_spec.name == "Company Policies" + assert search.mock_spec.output_schema["required"] == ["documents"] + assert search.mock_spec.example_calls == [] + deep = context_tool_from_resource( + _with(retrievalMode="DeepRAG", citationMode={"value": "Inline"}) + ) + spec = deep.mock_spec + assert spec.name == "Company Policies" + assert set(spec.output_schema["required"]) == {"text", "citations", "deepRagId"} + attached = deep_rag_tool_from_resource(DEEP_RAG_RESOURCE) + assert attached.name == "Deep_RAG_v2" + assert attached.mock_spec.name == "Deep RAG (v2)" + # An empty resource output schema: DeepRAG's result. + assert "deepRagId" in attached.mock_spec.output_schema["properties"] + with_schema = json.loads(json.dumps(DEEP_RAG_RESOURCE, default=str)) + with_schema["outputSchema"] = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + } + own = deep_rag_tool_from_resource(with_schema).mock_spec + assert own.output_schema["properties"] == {"answer": {"type": "string"}} + assert uipath_deep_rag_attachments_tool("Contract Research").mock_spec.name == ( + "Contract Research" + ) + + +def test_batch_integration_and_analyze_specs(): + batch = batch_transform_tool_from_resource(BATCH_RESOURCE) + assert batch.mock_spec.name == "Batch Transform" + assert batch.mock_spec.output_schema == BATCH_TRANSFORM_OUTPUT_SCHEMA + resource = json.loads(json.dumps(WEB_SEARCH_RESOURCE)) + resource["name"] = "Get User (v2)" + resource["properties"]["exampleCalls"] = [EXAMPLE] + integration = integration_tool_from_resource(resource) + assert integration.name == "Get_User_v2" + assert integration.mock_spec.name == "Get User (v2)" + assert "formattedResults" in integration.mock_spec.output_schema["properties"] + assert [e.id for e in integration.mock_spec.example_calls] == ["ex1"] + analyze = analyze_files_tool_from_resource(ANALYZE_RESOURCE) + assert analyze.mock_spec.name == "Analyze Files" + assert analyze.mock_spec.output_schema["properties"] == { + "analysis": {"type": "string"} + } + coded = uipath_analyze_files_tool() + assert coded.mock_spec.output_schema["required"] == ["analysisResult"] + + +class CustomTool: + """A user tool: no mock_spec, so the spec comes from its attributes.""" + + name = "lookup" + description = "Looks a key up." + input_schema = {"type": "object", "properties": {"key": {"type": "string"}}} + mocks_in_call = False + + def __init__(self, text: str = "real", attr: dict[str, Any] | None = None) -> None: + self.text = text + self.calls = 0 + for k, v in (attr or {}).items(): + setattr(self, k, v) + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + return dict(args) + + async def call(self, args: dict[str, Any], ctx: ToolContext) -> str: + self.calls += 1 + return self.text + + +def test_custom_tool_spec_from_attributes(): + spec = mock_spec_of(CustomTool()) + assert spec == MockSpec( + "lookup", + "Looks a key up.", + CustomTool.input_schema, + {"type": "object", "properties": {}}, + [], + ) + out = {"type": "object", "properties": {"v": {"type": "string"}}} + spec = mock_spec_of( + CustomTool(attr={"display_name": "Lookup Key", "output_json_schema": out}) + ) + assert spec.name == "Lookup Key" and spec.output_schema == out + + +# -------------------------------------------------------------------------- +# 3. the host: a simulated durable call is answered, never started or held +# -------------------------------------------------------------------------- +async def test_mocked_durable_call_answers_without_starting(serve): + tool = FakeTool(error=AssertionError("start must not run")) + with simulating(mockito(returns("a_plus_b", {"sum": 3}, kwargs={"a": 1, "b": 2}))): + served = await serve([tool]) + result = await served.call("a_plus_b", {"a": 1, "b": 2}) + assert result == { + "content": [{"type": "text", "text": '{"sum": 3}'}], + "isError": False, + } + assert tool.started == [] and served.probes == [] + assert served.host.held == {} and served.host.mocking_count == 0 + assert await served.store.get_pending() == {} + assert served.host.immediate_outcome("call_1") == '{"sum": 3}' + assert serve.pushed == [] + + +async def test_retry_gets_the_first_answer_and_a_raise_is_a_tool_error(serve): + behavior = { + "function": "a_plus_b", + "then": [ + {"type": "return", "value": {"sum": 3}}, + {"type": "raise", "value": {"_target_": "builtins.ValueError"}}, + ], + } + with simulating(mockito(behavior)): + served = await serve([FakeTool(error=AssertionError("start"))]) + first = await served.call("a_plus_b", {"a": 1, "b": 2}, call_id="c1") + retry = await served.call("a_plus_b", {"a": 1, "b": 2}, call_id="c1") + assert first == retry and first["isError"] is False + second = await served.call("a_plus_b", {"a": 1, "b": 2}, call_id="c2") + assert second["isError"] is True + assert _text(second) == "Tool 'a_plus_b' failed: ValueError" + # mockito arguments that no behavior matches: a tool error too + with simulating(mockito(returns("a_plus_b", {"sum": 3}, kwargs={"a": 1, "b": 2}))): + served = await serve([FakeTool(error=AssertionError("start"))]) + other = await served.call("a_plus_b", {"a": 5, "b": 2}) + assert other["isError"] is True and "Called but not expected" in _text(other) + + +class SlowShaping(FakeTool): + """A durable tool whose mocked result is shaped only once released.""" + + def __init__(self) -> None: + super().__init__(error=AssertionError("start must not run")) + self.release = asyncio.Event() + + async def mocked_output(self, args: dict[str, Any], value: Any, ctx: Any) -> str: + await self.release.wait() + return json.dumps(value) + + +async def test_retry_while_the_mocked_call_runs_waits_for_it(serve): + behavior = { + "function": "a_plus_b", + "then": [ + {"type": "return", "value": {"sum": 3}}, + {"type": "raise", "value": {"_target_": "builtins.ValueError"}}, + ], + } + tool = SlowShaping() + with simulating(mockito(behavior)): + served = await serve([tool]) + first = asyncio.create_task(served.call("a_plus_b", {"a": 1, "b": 2})) + for _ in range(100): + if served.host.mocking_count: + break + await asyncio.sleep(0.01) + # In flight: counted for the suspend watcher, neither held nor starting. + assert served.host.mocking_count == 1 and served.host.inflight_count == 1 + assert served.host.held == {} and served.host.starting_count == 0 + retry = asyncio.create_task(served.call("a_plus_b", {"a": 1, "b": 2})) + await asyncio.sleep(0.1) + tool.release.set() + assert await first == await retry + assert (await first)["isError"] is False # the mocker was asked once + assert served.host.mocking_count == 0 + + +async def test_unlisted_durable_tool_is_held_as_before(serve): + tool = FakeTool() + with simulating(mockito(returns("someone_else", 1))): + served = await serve([tool]) + task = asyncio.create_task(served.call("a_plus_b", {"a": 1, "b": 2})) + for _ in range(100): + if served.host.held: + break + await asyncio.sleep(0.02) + assert list(served.host.held) == ["call_1"] and served.probes == ["call_1"] + assert not task.done() + await served.host.release_all() + await asyncio.wait_for(task, 5) + + +async def test_llm_strategy_mocks_a_durable_call(serve, llm_answers): + llm_answers["a_plus_b"] = {"sum": 42} + tool = uipath_process_tool( + "a_plus_b", input_schema=AddIn, output_schema=AddOut, description="Adds." + ) + with simulating(llm("a_plus_b")): + served = await serve([tool]) + result = await served.call("a_plus_b", {"a": 40, "b": 2}) + assert result["isError"] is False and json.loads(_text(result)) == {"sum": 42} + [call] = llm_answers["__calls__"] + assert call["schema"]["properties"] == {"sum": {"type": "integer"}} # cleaned + assert '"kwargs": {"a": 40, "b": 2}' in call["prompt"] + + +# -------------------------------------------------------------------------- +# 4. custom immediate tools are gated by the host; built-ins mock in call +# -------------------------------------------------------------------------- +async def test_custom_immediate_tool_is_mocked_by_the_host(serve): + tool = CustomTool() + with simulating(mockito(returns("lookup", {"value": "simulated"}))): + served = await serve([tool]) + result = await served.call("lookup", {"key": "k"}) + assert json.loads(_text(result)) == {"value": "simulated"} and tool.calls == 0 + plain = CustomTool() + with simulating(mockito(returns("lookup", "plain text"))): + served = await serve([plain]) + assert _text(await served.call("lookup", {"key": "k"}, call_id=None)) == ( + "plain text" + ) + + +async def test_tool_that_mocks_in_call_is_not_gated(serve): + tool = CustomTool(attr={"mocks_in_call": True}) + with simulating(mockito(returns("lookup", {"value": "simulated"}))): + served = await serve([tool]) + result = await served.call("lookup", {"key": "k"}) + assert _text(result) == "real" and tool.calls == 1 + + +# -------------------------------------------------------------------------- +# 5. spans: low-code's "Simulated result" child, nothing held +# -------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("factory", "span_type"), + [ + (uipath_process_tool, "processTool"), + (uipath_agent_tool, "agentTool"), + ], +) +async def test_mocked_process_spans(serve, spans, factory, span_type): + tool = factory("a_plus_b", input_schema=AddIn) + with simulating(mockito(returns("a_plus_b", {"sum": 3}))): + served = await serve([tool], tracer=spans.tracer) + await served.call("a_plus_b", {"a": 1, "b": 2}) + by_name = _by_name(spans) + outer = by_name["Tool call - a_plus_b"] + child = by_name["Simulated result: a_plus_b"] + assert json.loads(outer.attributes["output.value"]) == {"content": '{"sum": 3}'} + assert outer.attributes["span_type"] == "toolCall" + assert MOCKED_ANNOTATION_KEY not in outer.attributes + assert child.attributes["span_type"] == span_type + assert child.attributes[MOCKED_ANNOTATION_KEY] is True + assert json.loads(child.attributes["output"]) == {"sum": 3} + assert child.parent.span_id == outer.context.span_id + assert serve.pushed == [] + + +async def test_custom_tool_marker_sits_on_the_tool_call(serve, spans): + with simulating(mockito(returns("lookup", {"value": "v"}))): + served = await serve([CustomTool()], tracer=spans.tracer) + await served.call("lookup", {"key": "k"}) + [span] = spans.get_finished_spans() + assert span.name == "Tool call - lookup" # never renamed + assert span.attributes[MOCKED_ANNOTATION_KEY] is True + assert span.attributes["tool.server"] == "uipath_tools" + + +# -------------------------------------------------------------------------- +# 6. shaping: the durable tools' result shapes and side effects +# -------------------------------------------------------------------------- +async def test_mocked_escalation_approve(serve, spans): + tool = escalation_tool_from_resource(HITL_RESOURCE) + answer = {"action": "approve", "data": {"Comment": "ok", "Content": "dropped"}} + with simulating(mockito(returns("escalate_color", answer))): + served = await serve([tool], tracer=spans.tracer) + result = await served.call("escalate_color", {"Content": "blue"}) + assert result["isError"] is False + assert json.loads(_text(result)) == { + "status": "completed", + "outcome": "approve", + "output": {"Comment": "ok"}, + "task_id": None, + "assigned_to": None, + } + child = _by_name(spans)["Simulated result: HITL App"] + assert child.attributes["span_type"] == "escalationTool" + assert json.loads(child.attributes["result"]) == { + "outcome": "approve", + "output": {"Comment": "ok"}, + } + assert not served.host.fatal.is_set() + + +async def test_mocked_escalation_matches_on_its_prefilled_fields(serve): + # The mocker sees the model's arguments with the hidden fields merged in, + # as langchain's StaticArgsHandler does: a behavior on them matches. + tool = uipath_escalation_tool( + "ExpenseApproval", + input_schema={ + "type": "object", + "properties": { + "Amount": {"type": "number"}, + "Department": {"type": "string"}, + "Requester": {"type": "string"}, + }, + }, + static_args={"Department": "Finance"}, + args_from_input={"Requester": "user.name"}, + ) + expected = {"Amount": 120, "Department": "Finance", "Requester": "Ana"} + answer = {"action": "approve", "data": {}} + with simulating(mockito(returns(tool.name.lower(), answer, kwargs=expected))): + served = await serve([tool], agent_input={"user": {"name": "Ana"}}) + assert "Department" not in tool.input_schema["properties"] + result = await served.call(tool.name, {"Amount": 120}) + assert result["isError"] is False, _text(result) + assert json.loads(_text(result))["outcome"] == "approve" + + +async def test_mocked_escalation_unresolved_argument(serve, spans): + # An args_from_input field the agent input lacks: the mocker sees the + # model's value; a required one neither filled is a tool error. + tool = uipath_escalation_tool( + "ExpenseApproval", + input_schema={ + "type": "object", + "properties": { + "Amount": {"type": "number"}, + "Requester": {"type": "string"}, + }, + "required": ["Amount", "Requester"], + }, + args_from_input={"Requester": "user.name"}, + ) + expected = {"Amount": 120, "Requester": "Bo"} + answer = {"action": "approve", "data": {}} + with simulating(mockito(returns(tool.name.lower(), answer, kwargs=expected))): + served = await serve([tool], tracer=spans.tracer, agent_input={}) + assert tool.input_schema["required"] == ["Amount"] + result = await served.call(tool.name, {"Amount": 120, "Requester": "Bo"}) + assert result["isError"] is False, _text(result) + outer = _by_name(spans)[f"Tool call - {tool.name}"] + assert json.loads(outer.attributes["input"]) == expected + result = await served.call(tool.name, {"Amount": 120}, call_id="call_2") + assert result["isError"] is True + assert "missing required argument(s): Requester" in _text(result) + # Refused before the escalationTool child opens, as a real start. + names = [s.name for s in spans.get_finished_spans()] + assert names.count(f"Tool call - {tool.name}") == 2 + assert "ExpenseApproval" not in names + + +def _with_attachment_argument(resource: dict[str, Any], key: str) -> dict[str, Any]: + """``resource`` with ``key`` read from the agent input's ``document``.""" + data = json.loads(json.dumps(resource)) + data["argumentProperties"] = { + key: {"variant": "argument", "argumentPath": "$.document", "isSensitive": False} + } + return data + + +@pytest.mark.parametrize( + ("resource", "tool_name", "mock_name", "doc", "answer"), + [ + ( + DEEP_RAG_RESOURCE, + "Deep_RAG_v2", + "Deep RAG (v2)", + DOC, + {"text": "Due in May.", "citations": [], "deepRagId": "d-1"}, + ), + ( + BATCH_RESOURCE, + "Batch_Transform", + "Batch Transform", + CSV, + {"result": {"ID": RESULT_ID, "FullName": "o.csv", "MimeType": "text/csv"}}, + ), + ], +) +async def test_mocked_attachment_tool_sees_the_merged_attachment( + serve, spans, resource, tool_name, mock_name, doc, answer +): + builder = ( + deep_rag_tool_from_resource + if resource is DEEP_RAG_RESOURCE + else batch_transform_tool_from_resource + ) + tool = builder(_with_attachment_argument(resource, "attachment")) + merged = {"query": "When?", "attachment": doc} + with simulating(mockito(returns(mock_name, answer, kwargs=merged))): + served = await serve( + [tool], + tracer=spans.tracer, + attachments=[doc], + agent_input={"document": doc}, + ) + result = await served.call(tool_name, {"query": "When?"}) + assert result["isError"] is False, _text(result) + assert json.loads(_text(result)) == answer + outer = _by_name(spans)[f"Tool call - {tool_name}"] + assert json.loads(outer.attributes["input"]) == merged + # The attachment taken from the agent input is the span's too. + listed = json.loads(outer.attributes["attachments"]) + assert [a["id"] for a in listed if a["direction"] == 1] == [doc["ID"]] + # The agent input has none: the model's (host-resolved) attachment. + with simulating(mockito(returns(mock_name, answer, kwargs=merged))): + served = await serve( + [tool], tracer=spans.tracer, attachments=[doc], agent_input={} + ) + result = await served.call( + tool_name, {"query": "When?", "attachment": {"ID": doc["ID"]}} + ) + assert result["isError"] is False, _text(result) + spans.clear() + result = await served.call(tool_name, {"query": "When?"}, call_id="call_2") + assert result["isError"] is True + assert "missing required argument(s): attachment" in _text(result) + # Refused before the tool's child span opens, as a real start. + assert [s.name for s in spans.get_finished_spans()] == [f"Tool call - {tool_name}"] + + +async def test_mocked_analyze_files_sees_the_merged_attachments(spans): + tool = analyze_files_tool_from_resource( + _with_attachment_argument(ANALYZE_RESOURCE, "attachments") + ) + store = AttachmentStore([UiPathAttachment.model_validate(PNG)]) + ctx = ToolContext("c1", "rt", "Analyze_Files", {"document": PNG}, store, "gpt") + behavior = returns( + "Analyze Files", + {"analysis": "42"}, + kwargs={"attachments": [PNG], "analysisTask": "Total?"}, + ) + with simulating(mockito(behavior)): + text = await tool.call({"analysisTask": "Total?"}, ctx) + assert json.loads(text) == {"analysis": "42"} + # unresolved: the model's attachments + unresolved = ToolContext("c1", "rt", "Analyze_Files", {}, store, "gpt") + text = await tool.call( + {"analysisTask": "Total?", "attachments": [PNG]}, unresolved + ) + assert json.loads(text) == {"analysis": "42"} + with pytest.raises(at.UiPathToolError, match="missing required"): + await tool.call({"analysisTask": "Total?"}, unresolved) + + +async def test_mocked_process_matches_on_its_prefilled_fields(serve): + # As for an escalation: the mocker sees the job input the call would + # start with, so a behavior on the pre-filled fields matches. + tool = uipath_process_tool( + "PlaceOrder", + input_schema={ + "type": "object", + "properties": { + "sku": {"type": "string"}, + "channel": {"type": "string"}, + "customer": {"type": "string"}, + }, + "required": ["sku", "channel"], + }, + static_args={"channel": "codex"}, + args_from_input={"customer": "user.email"}, + ) + expected = {"sku": "A-1", "channel": "codex", "customer": "ana@example.com"} + with simulating(mockito(returns("PlaceOrder", {"ok": True}, kwargs=expected))): + served = await serve([tool], agent_input={"user": {"email": "ana@example.com"}}) + result = await served.call(tool.name, {"sku": "A-1"}) + assert result["isError"] is False, _text(result) + assert json.loads(_text(result)) == {"ok": True} + + +async def test_mocked_process_span_arguments_are_merged(serve, spans): + from .test_process_tools import AGENT_INPUT, INVOICE + + tool = process_tool_from_resource(INVOICE) + with simulating(mockito(returns("Create Invoice", {"invoiceId": "I-1"}))): + served = await serve([tool], tracer=spans.tracer, agent_input=AGENT_INPUT) + result = await served.call("Create_Invoice", {"amount": 10}) + assert result["isError"] is False, _text(result) + merged = { + "amount": 10, + "currency": "EUR", + "apiKey": "s3cr3t", # isSensitive: clear text, as in low-code + "customerId": "C1", + "memo": "Invoice for Ada", + "tags": ["agent", "EU"], + } + by_name = _by_name(spans) + outer = by_name["Tool call - Create_Invoice"] + assert json.loads(outer.attributes["input"]) == merged + assert outer.attributes["toolType"] == "Process" + child = by_name["Simulated result: CreateInvoice"] + assert child.attributes["span_type"] == "processTool" + assert json.loads(child.attributes["arguments"]) == merged + + +async def test_mocked_flow_has_no_process_span(serve, spans): + # low-code opens no processTool span for a Flow: the marker sits on the + # Tool call, whose toolType is low-code's Integration fallback. + from .test_process_tools import _resource + + tool = process_tool_from_resource(_resource(type="flow", argumentProperties={})) + with simulating(mockito(returns("Create Invoice", {"invoiceId": "I-1"}))): + served = await serve([tool], tracer=spans.tracer) + await served.call( + "Create_Invoice", + {"amount": 1, "customerId": "C", "currency": "EUR", "apiKey": "k"}, + ) + [span] = spans.get_finished_spans() + assert span.name == "Tool call - Create_Invoice" + assert span.attributes["toolType"] == "Integration" + assert span.attributes[MOCKED_ANNOTATION_KEY] is True + + +class CustomerIn(BaseModel): + customer_id: str + amount: int + + +@pytest.mark.parametrize("mocked", [False, True]) +async def test_invalid_merged_input_is_a_tool_error(serve, spans, mocked): + # The agent input's value fails the model once merged: the call is + # answered with a tool error, no job starts (the platform is refused) + # and the Tool call span ends with the error. + tool = uipath_process_tool( + "CreateInvoice", + input_schema=CustomerIn, + args_from_input={"customer_id": "customer.id"}, + ) + strategy = mockito(returns("CreateInvoice", {"ok": True})) if mocked else None + with simulating(strategy) if mocked else contextlib.nullcontext(): + served = await serve( + [tool], tracer=spans.tracer, agent_input={"customer": {"id": 5}} + ) + result = await served.call("CreateInvoice", {"amount": 1}) + assert result["isError"] is True + assert "Invalid arguments for tool 'CreateInvoice'" in _text(result) + finished = spans.get_finished_spans() + assert [s.name for s in finished] == ["Tool call - CreateInvoice"] + assert finished[0].status.status_code.name == "ERROR" + assert json.loads(finished[0].attributes["input"]) == { + "amount": 1, + "customer_id": 5, + } + assert await served.store.get_pending() == {} + assert serve.pushed == [] + + +async def test_unresolved_required_argument_is_a_tool_error(serve, spans): + # A resource's agent-input field is optional for the model; when the + # agent input has no value either, the model is asked to fill it. + from .test_process_tools import INVOICE + + tool = process_tool_from_resource(INVOICE) + served = await serve([tool], tracer=spans.tracer, agent_input={}) + result = await served.call("Create_Invoice", {"amount": 10}) + assert result["isError"] is True + assert "missing required argument(s): customerId" in _text(result) + [span] = spans.get_finished_spans() + assert span.status.status_code.name == "ERROR" + + +@pytest.mark.parametrize( + "answer", + [ + {"action": "reject", "data": {"Comment": "no"}}, # mapped to "end" + {"action": "approve", "data": {}, "is_deleted": True}, + ], +) +async def test_mocked_escalation_that_ends_the_run(serve, spans, answer): + tool = escalation_tool_from_resource(HITL_RESOURCE) + with simulating(mockito(returns("escalate_color", answer))): + served = await serve([tool], tracer=spans.tracer) + result = await served.call("escalate_color", {"Content": "blue"}) + assert result["isError"] is True + assert served.host.fatal.is_set() + error = served.host.fatal_error + assert error is not None + assert error.error_info.code.endswith( + UiPathCodexErrorCode.TERMINATION_ESCALATION_REJECTED.value + ) + outer = _by_name(spans)["Tool call - escalate_color"] + assert outer.status.status_code.name == "ERROR" + + +async def test_mocked_ixp_keeps_the_extraction_for_validation(serve, spans): + from uipath.platform.documents import ExtractionResponseIXP + + ixp = ixp_extraction_tool_from_resource(IXP_RESOURCE) + vs = vs_escalation_tool_from_resource(_vs_resource()) + with simulating( + mockito( + returns("Invoice Extraction", extraction()), + returns("Validate Invoice", {"data": FakeDocuments.projection("4250")}), + ) + ): + served = await serve([ixp, vs], tracer=spans.tracer, attachments=[DOC]) + # validation first: no extraction yet → the "call it first" error, no spans + early = await served.call("Validate_Invoice", {}, call_id="v0") + assert early["isError"] is True and "Call it first" in _text(early) + assert spans.get_finished_spans() == () + result = await served.call("Invoice_Extraction", {"attachment": {"ID": DOC_ID}}) + assert result["isError"] is False + assert json.loads(_text(result)) == FakeDocuments.projection() + stored = served.host.results.get("Invoice Extraction") + assert ExtractionResponseIXP.model_validate(stored).data_projection is not None + assert stored["projectId"] == "p1" # alias-keyed, as LangChain's round trip + child = _by_name(spans)["Simulated result: Invoice Extraction"] + assert child.attributes["span_type"] == "ixpTool" + validated = await served.call("Validate_Invoice", {}, call_id="v1") + assert json.loads(_text(validated)) == {"data": FakeDocuments.projection("4250")} + # the vsEscalationTool child is named after the app, as a real one + child = _by_name(spans)["Simulated result: HITL App"] + assert child.attributes["span_type"] == "vsEscalationTool" + + +async def test_malformed_ixp_result_is_a_tool_error(serve): + ixp = ixp_extraction_tool_from_resource(IXP_RESOURCE) + with simulating(mockito(returns("Invoice Extraction", {"nope": 1}))): + served = await serve([ixp], attachments=[DOC]) + result = await served.call("Invoice_Extraction", {"attachment": {"ID": DOC_ID}}) + assert result["isError"] is True + assert "Unexpected IXP extraction result" in _text(result) + assert served.host.results.get("Invoice Extraction") is None + + +async def test_mocked_batch_transform_result_becomes_known(serve, spans): + tool = batch_transform_tool_from_resource(BATCH_RESOURCE) + answer = { + "result": {"ID": RESULT_ID, "FullName": "output.csv", "MimeType": "text/csv"} + } + with simulating(mockito(returns("Batch Transform", answer))): + served = await serve([tool], tracer=spans.tracer, attachments=[CSV]) + result = await served.call( + "Batch_Transform", {"attachment": {"ID": CSV["ID"]}, "query": "Add topics."} + ) + assert json.loads(_text(result)) == answer + assert served.host.attachments.get(RESULT_ID) is not None + child = _by_name(spans)["Simulated result: Batch_Transform"] + assert child.attributes["retrieval_mode"] == "BatchTransform" + + +async def test_mocked_deep_rag_passes_the_value_through(serve, spans): + tool = context_tool_from_resource( + _with(retrievalMode="DeepRAG", citationMode={"value": "Inline"}) + ) + answer = {"text": "Due in May.", "citations": [], "deepRagId": "d-1"} + with simulating(mockito(returns("Company Policies", answer))): + served = await serve([tool], tracer=spans.tracer) + result = await served.call("Company_Policies", {"query": "When?"}) + assert json.loads(_text(result)) == answer + child = _by_name(spans)["Simulated result: Company Policies"] + assert child.attributes["span_type"] == "contextGroundingTool" + assert child.attributes["query"] == "When?" + + +# -------------------------------------------------------------------------- +# 7. the built-in immediate tools mock inside call, under their span +# -------------------------------------------------------------------------- +CTX = ToolContext(call_id="c1", runtime_id="rt", tool_name="t") + + +async def test_integration_tool_is_mocked_by_resource_name(spans, llm_answers): + resource = json.loads(json.dumps(WEB_SEARCH_RESOURCE)) + resource["name"] = "Get User (v2)" + tool = integration_tool_from_resource(resource) + llm_answers["Get User (v2)"] = {"formattedResults": "simulated"} + with simulating(llm("Get User (v2)")): + text = await tool.call({"query": "x"}, CTX) + assert json.loads(text) == {"formattedResults": "simulated"} + child = _by_name(spans)["Simulated result: Get User (v2)"] + assert child.attributes["span_type"] == "integrationTool" + assert child.attributes[MOCKED_ANNOTATION_KEY] is True + # The model-facing name is not the mock name: the real call runs. + with ( + simulating(llm("Get_User_v2")), + pytest.raises(AssertionError, match="platform"), + ): + await tool.call({"query": "x"}, CTX) + + +async def test_context_search_is_mocked_without_a_retriever_span(spans): + tool = uipath_context_tool("Company Policies", index_name="i") + docs = {"documents": [{"metadata": {}, "page_content": "Travel by train."}]} + with simulating(mockito(returns("Company Policies", docs))): + text = await tool.call({"query": "travel"}, CTX) + assert json.loads(text) == docs + names = [s.name for s in spans.get_finished_spans()] + assert names == ["Simulated result: Company Policies"] + [span] = spans.get_finished_spans() + assert span.attributes["span_type"] == "contextGroundingTool" + assert json.loads(span.attributes["output"]) == docs + + +def _analyze_ctx() -> ToolContext: + store = AttachmentStore([UiPathAttachment.model_validate(PNG)]) + return ToolContext("c1", "rt", "Analyze_Files", None, store, "gpt") + + +async def test_analyze_files_is_mocked_with_the_full_attachments(spans): + tool = uipath_analyze_files_tool() + # The mocker sees the attachment as the full JobAttachment (langchain's). + behavior = returns( + "Analyze Files", + {"analysisResult": "42"}, + kwargs={"attachments": [PNG], "analysisTask": "Total?"}, + ) + with simulating(mockito(behavior)): + text = await tool.call( + {"attachments": [{"ID": PNG["ID"]}], "analysisTask": "Total?"}, + _analyze_ctx(), + ) + assert json.loads(text) == {"analysisResult": "42"} + [span] = spans.get_finished_spans() + assert span.name == "Simulated result: Analyze Files" + assert span.attributes["span_type"] == "toolCall" + + +@pytest.mark.parametrize( + "args", + [ + {"attachments": [], "analysisTask": "Total?"}, # the no-files answer + {"attachments": [PNG]}, # no analysisTask + {"analysisTask": "Total?"}, # no attachments + ], +) +async def test_analyze_files_argument_checks_are_inside_the_mock(spans, args): + """As langchain's mocked tool_fn: the mocker answers before the checks.""" + tool = uipath_analyze_files_tool() + with simulating(mockito(returns("Analyze Files", {"analysisResult": "42"}))): + text = await tool.call(args, _analyze_ctx()) + assert json.loads(text) == {"analysisResult": "42"} + [span] = spans.get_finished_spans() + assert span.name == "Simulated result: Analyze Files" + # not simulated: the checks still answer + with pytest.raises(Exception, match="analysisTask"): + await uipath_analyze_files_tool().call({"attachments": [PNG]}, _analyze_ctx()) + + +async def test_context_search_mocker_gets_langchains_arguments(): + docs = {"documents": [{"metadata": {}, "page_content": "Travel by train."}]} + tool = uipath_context_tool( + "Company Policies", index_name="i", folder_path_prefix="hr" + ) + with simulating( + mockito(returns("Company Policies", docs, kwargs={"query": "travel"})) + ): + assert json.loads(await tool.call({"query": "travel"}, CTX)) == docs + # A static query: no arguments. + static = uipath_context_tool("Company Policies", index_name="i", query="travel") + with simulating(mockito(returns("Company Policies", docs, kwargs={}))): + assert json.loads(await static.call({}, CTX)) == docs + # A query set from the agent input: that query (langchain applies it). + from_input = uipath_context_tool( + "Company Policies", index_name="i", query_from_input="topic" + ) + ctx = ToolContext("c1", "rt", "t", {"topic": "travel"}) + with simulating( + mockito(returns("Company Policies", docs, kwargs={"query": "travel"})) + ): + assert json.loads(await from_input.call({"query": "other"}, ctx)) == docs + + +async def test_integration_mocker_gets_the_activity_input(): + tool = integration_tool_from_resource(WEB_SEARCH_RESOURCE) + payload = {"query": "x", "provider": "GoogleCustomSearch"} # static merged + answer = {"formattedResults": "simulated"} + with simulating(mockito(returns("Web Search", answer, kwargs=payload))): + assert json.loads(await tool.call({"query": "x"}, CTX)) == answer + + +async def test_mocked_deep_rag_on_an_attachment(serve, spans): + tool = deep_rag_tool_from_resource(DEEP_RAG_RESOURCE) + answer = {"text": "Due in May.", "citations": [], "deepRagId": "d-1"} + with simulating(mockito(returns("Deep RAG (v2)", answer))): + served = await serve([tool], tracer=spans.tracer, attachments=[DOC]) + result = await served.call( + "Deep_RAG_v2", {"attachment": {"ID": DOC_ID}, "query": "When?"} + ) + assert result["isError"] is False and json.loads(_text(result)) == answer + assert served.probes == [] and served.host.held == {} + by_name = _by_name(spans) + child = by_name["Simulated result: Deep_RAG_v2"] # as a real call's span + assert child.attributes["span_type"] == "toolCall" + assert child.attributes["citation_mode"] == "Inline" + assert child.attributes[MOCKED_ANNOTATION_KEY] is True + assert json.loads(child.attributes["output"]) == answer + assert child.parent.span_id == by_name["Tool call - Deep_RAG_v2"].context.span_id + + +async def test_simulated_component_answers_a_durable_call(serve, monkeypatch): + import uipath.eval.mocks._simulate_component_mocker as component_mocker + + payloads: list[dict[str, Any]] = [] + + class Service: + async def simulate(self, payload: dict[str, Any]) -> dict[str, Any]: + payloads.append(payload) + return {"status": 1, "simulatedOutput": {"sum": 7}} + + monkeypatch.setattr( + component_mocker, "_create_simulate_component_service", lambda: Service() + ) + tool = FakeTool(error=AssertionError("start must not run")) + # "a plus b" matches "a_plus_b" for both the gate and the mocker. + with simulating(components=[ComponentSimulationConfig(component_id="a plus b")]): + served = await serve([tool]) + result = await served.call("a_plus_b", {"a": 3, "b": 4}) + assert result["isError"] is False and json.loads(_text(result)) == {"sum": 7} + assert tool.started == [] and served.probes == [] + [payload] = payloads + assert payload["componentId"] == "a plus b" + assert payload["input"] == {"args": [], "kwargs": {"a": 3, "b": 4}} + + +async def test_runtime_error_from_a_mocked_result_fails_the_run(serve, spans): + from uipath.runtime.errors import UiPathErrorCategory + + from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError + + error = UiPathCodexRuntimeError( + UiPathCodexErrorCode.EXECUTION_FAILED, + "Bad result", + "no", + UiPathErrorCategory.USER, + ) + + class Failing(FakeTool): + async def mocked_output( + self, args: dict[str, Any], value: Any, ctx: Any + ) -> str: + raise error + + with simulating(mockito(returns("a_plus_b", {"sum": 3}))): + served = await serve( + [Failing(error=AssertionError("start"))], tracer=spans.tracer + ) + result = await served.call("a_plus_b", {"a": 1, "b": 2}) + assert result["isError"] is True and _text(result) == "Tool 'a_plus_b' failed: no" + assert served.host.fatal.is_set() and served.host.fatal_error is error + outer = _by_name(spans)["Tool call - a_plus_b"] + assert outer.status.status_code.name == "ERROR" + + +def test_mocks_in_call_flags(): + assert integration_tool_from_resource(WEB_SEARCH_RESOURCE).mocks_in_call is True + assert uipath_context_tool("c", index_name="i").mocks_in_call is True + assert uipath_analyze_files_tool().mocks_in_call is True + assert getattr(uipath_process_tool("p"), "mocks_in_call", False) is False diff --git a/packages/uipath-codex-sdk/tests/test_prefilled_attachments.py b/packages/uipath-codex-sdk/tests/test_prefilled_attachments.py new file mode 100644 index 00000000..8dece2af --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_prefilled_attachments.py @@ -0,0 +1,542 @@ +"""Pre-filled attachment arguments: the host checks the merged arguments. + +As in LangChain, whose ``StaticArgsHandler`` merges the pre-filled values +(``argumentProperties`` / ``static_args`` / ``args_from_input``) into the tool +call before ``job_attachment_wrapper`` resolves its job attachments: a model +value a pre-filled one replaces is not checked (the pre-filled one is), and a +model value no pre-filled one replaces is resolved or refused as before. +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic import BaseModel + +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + AttachmentStore, + UiPathAttachment, + analyze_files_tool_from_resource, + uipath_escalation_tool, + uipath_process_tool, +) +from uipath_codex_sdk._fixed_args import fixed_args_from_code, with_fixed_args +from uipath_codex_sdk.attachment_tools import UiPathAnalyzeFilesTool +from uipath_codex_sdk.attachments import not_found_message +from uipath_codex_sdk.batch_transform_tools import batch_transform_tool_from_resource +from uipath_codex_sdk.context_tools import deep_rag_tool_from_resource + +from .test_analyze_files import PDF, PNG, PNG_ID, services # noqa: F401 - fixture +from .test_analyze_files import RESOURCE as ANALYZE_RESOURCE +from .test_attachments import ( + INVOICE, + INVOICE_ID, + REPORT, + REPORT_ID, + UNKNOWN_ID, + AttachmentTool, + Input, +) +from .test_batch_transform_attachments import CSV +from .test_batch_transform_attachments import RESOURCE as BATCH_RESOURCE +from .test_deep_rag_attachments import DOC +from .test_deep_rag_attachments import RESOURCE as DEEP_RAG_RESOURCE +from .test_escalation_tools import fake # noqa: F401 - fixture +from .test_mocking import ( + RESULT_ID, + _by_name, + _with_attachment_argument, + mockito, + returns, + serve, # noqa: F401 - fixture + simulating, + spans, # noqa: F401 - fixture +) +from .test_mocking import _text as _result_text +from .test_tool_host import _provider, _text, _until, make_host # noqa: F401 + +INVOICE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "invoice": {"$ref": "#/definitions/job-attachment"}, + "question": {"type": "string"}, + }, + "required": ["invoice", "question"], + "definitions": { + "job-attachment": { + "type": "object", + "x-uipath-resource-kind": "JobAttachment", + "properties": {"ID": {"type": "string"}}, + } + }, +} + + +def _known(*items: dict[str, Any]) -> AttachmentStore: + return AttachmentStore(UiPathAttachment.model_validate(a) for a in items) + + +@pytest.fixture +def jobs(monkeypatch): + """Orchestrator's ``invoke_async``: records each job start.""" + from uipath.platform.orchestrator import Job + + calls: list[dict[str, Any]] = [] + + async def invoke(**kwargs: Any) -> Job: + calls.append(kwargs) + return Job.model_validate({"Id": 1, "Key": "k", "State": "Pending"}) + + monkeypatch.setattr( + tools_mod, + "_uipath_client", + lambda: SimpleNamespace(processes=SimpleNamespace(invoke_async=invoke)), + ) + return calls + + +async def _held(h: Any, name: str, args: dict[str, Any]) -> asyncio.Task[Any]: + """Call durable tool ``name`` and wait until its job started and it is held.""" + task = asyncio.create_task(h.call_tool(name, args)) + await _until(lambda: bool(h.host.held) or task.done()) + assert not task.done(), _text(task.result()[1]) + return task + + +# --- durable process tools ---------------------------------------------------- + + +@pytest.mark.parametrize("model_value", [{"ID": UNKNOWN_ID}, {"ID": "not-a-uuid"}]) +async def test_agent_input_attachment_wins_over_an_unknown_model_id( + make_host, # noqa: F811 + jobs, + model_value, +): + tool = uipath_process_tool( + "Summarize", input_schema=Input, args_from_input={"invoice": "doc"} + ) + h = await make_host( + [tool], + with_store=True, + agent_input={"doc": INVOICE}, + attachments=_known(INVOICE), + ) + args = {"invoice": model_value, "question": "q"} + held = await _held(h, "Summarize", args) + [call] = jobs + assert call["input_arguments"]["invoice"]["ID"] == INVOICE_ID + assert [str(a.id) for a in call["attachments"]] == [INVOICE_ID] + # The model's arguments are what is saved for resume, as sent. + pending = (await h.store.get_pending())["call_is"] + assert pending.args == args + await h.host.release_all() + await asyncio.wait_for(held, 5) + + +async def test_a_static_attachment_wins_over_an_unknown_model_id( + make_host, # noqa: F811 + jobs, +): + tool = uipath_process_tool( + "Summarize", input_schema=INVOICE_SCHEMA, static_args={"invoice": INVOICE} + ) + assert "invoice" not in tool.input_schema["properties"] + h = await make_host([tool], with_store=True, attachments=_known(INVOICE)) + # The model passes the hidden field anyway: the static value replaces it. + held = await _held(h, "Summarize", {"invoice": {"ID": UNKNOWN_ID}, "question": "q"}) + [call] = jobs + assert call["input_arguments"]["invoice"] == INVOICE + await h.host.release_all() + await asyncio.wait_for(held, 5) + + +async def test_a_static_attachment_the_run_does_not_know_is_refused( + make_host, # noqa: F811 + jobs, +): + # As LangChain, which resolves the tool call once the static args are in. + tool = uipath_process_tool( + "Summarize", input_schema=INVOICE_SCHEMA, static_args={"invoice": REPORT} + ) + h = await make_host([tool], with_store=True, attachments=_known(INVOICE)) + _, body = await h.call_tool("Summarize", {"question": "q"}) + assert body["result"]["isError"] is True + assert _text(body) == not_found_message(REPORT_ID) + assert jobs == [] and await h.store.get_pending() == {} + + +async def test_an_unknown_model_id_with_no_prefill_for_it_is_refused( + make_host, # noqa: F811 + jobs, +): + # The agent input has no ``doc`` (so the model's value stays), and the + # static field is another one. + tool = uipath_process_tool( + "Summarize", + input_schema=Input, + static_args={"question": "q"}, + args_from_input={"invoice": "doc"}, + ) + h = await make_host( + [tool], with_store=True, agent_input={}, attachments=_known(INVOICE) + ) + _, body = await h.call_tool("Summarize", {"invoice": {"ID": UNKNOWN_ID}}) + assert body["result"]["isError"] is True + assert _text(body) == not_found_message(UNKNOWN_ID) + _, body = await h.call_tool( + "Summarize", {"invoice": {"ID": "not-a-uuid"}}, call_id="call_2" + ) + assert _text(body) == not_found_message("not-a-uuid") + assert jobs == [] and await h.store.get_pending() == {} + # A known one is resolved, and is the job's. + held = await _held(h, "Summarize", {"invoice": {"ID": INVOICE_ID}}) + [call] = jobs + assert call["input_arguments"]["invoice"]["FullName"] == "invoice.pdf" + assert call["input_arguments"]["question"] == "q" + await h.host.release_all() + await asyncio.wait_for(held, 5) + + +async def test_an_unknown_model_id_in_a_list_item_no_prefill_replaces( + make_host, # noqa: F811 + jobs, +): + # ``invoice`` comes from the agent input; ``extra`` is the model's. + tool = uipath_process_tool( + "Summarize", input_schema=Input, args_from_input={"invoice": "doc"} + ) + h = await make_host( + [tool], + with_store=True, + agent_input={"doc": INVOICE}, + attachments=_known(INVOICE, REPORT), + ) + _, body = await h.call_tool( + "Summarize", + { + "invoice": {"ID": UNKNOWN_ID}, + "extra": [{"ID": REPORT_ID}, {"ID": UNKNOWN_ID}], + "question": "q", + }, + ) + assert body["result"]["isError"] is True + assert _text(body) == not_found_message(UNKNOWN_ID) # only extra[1]'s + assert jobs == [] + + +async def test_an_invalid_model_value_in_an_input_field_is_still_refused( + make_host, # noqa: F811 + jobs, +): + # Only an attachment the agent input fills is excused before the merge: a + # model value of the wrong type elsewhere is refused up front, as before. + class Order(BaseModel): + sku: str + customer: int + + tool = uipath_process_tool( + "P", input_schema=Order, args_from_input={"customer": "user.id"} + ) + with pytest.raises(ValueError): + tool.validate_args({"sku": "A", "customer": "not-int"}) + assert tool.validate_args({"sku": "A"}) == {"sku": "A"} # missing: excused + h = await make_host([tool], with_store=True, agent_input={}) + _, body = await h.call_tool("P", {"sku": "A", "customer": "not-int"}) + assert body["result"]["isError"] is True + assert _text(body).startswith("Invalid arguments for tool 'P':") + assert jobs == [] and await h.store.get_pending() == {} + + +# --- escalation: LangChain never resolves its attachments ---------------------- + +RECEIPT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "Amount": {"type": "number"}, + "Receipt": {"$ref": "#/definitions/job-attachment"}, + }, + "required": ["Amount", "Receipt"], + "definitions": INVOICE_SCHEMA["definitions"], +} + + +async def test_escalation_static_attachment_the_run_does_not_know_creates_the_task( + make_host, # noqa: F811 + fake, # noqa: F811 +): + tool = uipath_escalation_tool( + "ExpenseApproval", + folder_path="Shared", + input_schema=RECEIPT_SCHEMA, + static_args={"Receipt": REPORT}, + ) + h = await make_host([tool], with_store=True, attachments=_known(INVOICE)) + task = asyncio.create_task(h.call_tool(tool.name, {"Amount": 1})) + await _until(lambda: bool(h.host.held) or task.done()) + assert not task.done(), _text(task.result()[1]) + [created] = fake.created + assert created["data"]["Receipt"] == REPORT + await h.host.release_all() + await asyncio.wait_for(task, 5) + + +async def test_escalation_input_attachment_is_not_checked( + make_host, # noqa: F811 + fake, # noqa: F811 +): + # The agent input's attachment replaces the model's known one; neither is + # checked against the run's attachments (the input's is unknown here). + tool = uipath_escalation_tool( + "ExpenseApproval", + folder_path="Shared", + input_schema=RECEIPT_SCHEMA, + args_from_input={"Receipt": "receipt"}, + ) + h = await make_host( + [tool], + with_store=True, + agent_input={"receipt": REPORT}, + attachments=_known(INVOICE), + ) + args = {"Amount": 1, "Receipt": {"ID": INVOICE_ID}} + task = asyncio.create_task(h.call_tool(tool.name, args)) + await _until(lambda: bool(h.host.held) or task.done()) + assert not task.done(), _text(task.result()[1]) + [created] = fake.created + assert created["data"]["Receipt"] == REPORT + await h.host.release_all() + await asyncio.wait_for(task, 5) + + +# --- Analyze Files (immediate) -------------------------------------------------- + + +def _analyze_from(argument_properties: dict[str, Any]) -> UiPathAnalyzeFilesTool: + return analyze_files_tool_from_resource( + {**ANALYZE_RESOURCE, "argumentProperties": argument_properties} + ) + + +async def test_analyze_files_array_builder_wins_over_unknown_model_ids( + make_host, # noqa: F811 + services, # noqa: F811 +): + tool = _analyze_from( + { + "$['attachments']": {"variant": "arrayBuilder"}, + "$['attachments'][0]": { + "variant": "argument", + "argumentPath": "$['document']", + "isSensitive": False, + }, + } + ) + h = await make_host( + [tool], agent_input={"document": PDF}, attachments=_known(PNG, PDF), model="m" + ) + _, body = await h.call_tool( + "Analyze_Files", + {"attachments": [{"ID": UNKNOWN_ID}, {"ID": "x"}], "analysisTask": "Sum"}, + ) + assert body["result"]["isError"] is False, _text(body) + [request] = services.gateway.requests + [_, part] = request["body"]["input"][0]["content"] + assert part["type"] == "input_file" and part["file_data"].startswith( + "data:application/pdf" + ) + + +async def test_analyze_files_static_items_win_over_unknown_model_ids( + make_host, # noqa: F811 + services, # noqa: F811 +): + # ``attachments[*]``: every item the model sends becomes the static one. + exporter, tracer = _provider() + tool = UiPathAnalyzeFilesTool( + name="Analyze_Files", + description="Analyze.", + fixed_args=fixed_args_from_code({"attachments[*]": PNG}, None), + ) + h = await make_host([tool], tracer=tracer, attachments=_known(PNG), model="m") + _, body = await h.call_tool( + "Analyze_Files", + {"attachments": [{"ID": UNKNOWN_ID}, {"ID": UNKNOWN_ID}], "analysisTask": "?"}, + ) + assert body["result"]["isError"] is False, _text(body) + [request] = services.gateway.requests + types = [p["type"] for p in request["body"]["input"][0]["content"][1:]] + assert types == ["input_image", "input_image"] + outer = _by_name(exporter)["Tool call - Analyze_Files"] + assert json.loads(outer.attributes["input"])["attachments"] == [PNG, PNG] + listed = json.loads(outer.attributes["attachments"]) + assert [a["id"] for a in listed] == [PNG_ID, PNG_ID] + + +async def test_analyze_files_argument_with_no_input_value_checks_the_model_ids( + make_host, # noqa: F811 + services, # noqa: F811 +): + tool = _analyze_from( + { + "attachments": { + "variant": "argument", + "argumentPath": "$.documents", + "isSensitive": False, + } + } + ) + # The agent input has the files: the model's unknown one is replaced. + h = await make_host( + [tool], + agent_input={"documents": [PDF]}, + attachments=_known(PNG, PDF), + model="m", + ) + args = {"attachments": [{"ID": UNKNOWN_ID}], "analysisTask": "Sum"} + _, body = await h.call_tool("Analyze_Files", args) + assert body["result"]["isError"] is False, _text(body) + # It has none: the model's are checked. + h = await make_host([tool], agent_input={}, attachments=_known(PNG, PDF), model="m") + _, body = await h.call_tool("Analyze_Files", args) + assert body["result"]["isError"] is True + assert _text(body) == not_found_message(UNKNOWN_ID) + assert len(services.gateway.requests) == 1 + + +# --- custom tools ------------------------------------------------------------------ + + +class PrefilledAttachmentTool(AttachmentTool): + """A custom tool whose ``file`` is pre-filled from the agent input.""" + + prefilled_schema = AttachmentTool.input_schema + + def prefilled_args( + self, args: dict[str, Any], agent_input: dict[str, Any] | None + ) -> dict[str, Any]: + merged = dict(args) + if agent_input and "doc" in agent_input: + merged["file"] = agent_input["doc"] + return merged + + +async def test_a_custom_tool_hook(make_host): # noqa: F811 + tool = PrefilledAttachmentTool() + h = await make_host( + [tool], agent_input={"doc": INVOICE}, attachments=_known(INVOICE), model="gpt-x" + ) + _, body = await h.call_tool("inspect_file", {"file": {"ID": UNKNOWN_ID}}) + assert body["result"]["isError"] is False, _text(body) + assert tool.calls == [{"file": {"ID": UNKNOWN_ID}}] # the model's, as sent + h = await make_host([tool], agent_input={}, attachments=_known(INVOICE)) + _, body = await h.call_tool("inspect_file", {"file": {"ID": UNKNOWN_ID}}) + assert _text(body) == not_found_message(UNKNOWN_ID) + + +# --- simulated (eval-mocked) calls --------------------------------------------------- + + +async def test_mocked_process_call_sees_the_prefilled_attachment( + serve, # noqa: F811 + spans, # noqa: F811 +): + tool = uipath_process_tool( + "Summarize", input_schema=INVOICE_SCHEMA, static_args={"invoice": INVOICE} + ) + merged = {"invoice": INVOICE, "question": "q"} + with simulating(mockito(returns("Summarize", {"ok": 1}, kwargs=merged))): + served = await serve([tool], tracer=spans.tracer, attachments=[INVOICE]) + result = await served.call( + "Summarize", {"invoice": {"ID": UNKNOWN_ID}, "question": "q"} + ) + assert result["isError"] is False, _result_text(result) + assert served.probes == [] and served.host.held == {} + outer = _by_name(spans)["Tool call - Summarize"] + assert json.loads(outer.attributes["input"]) == merged + # The hidden static attachment is listed with the call's. + listed = json.loads(outer.attributes["attachments"]) + assert [a["id"] for a in listed] == [INVOICE_ID] + + +async def test_mocked_escalation_sees_the_input_attachment(serve): # noqa: F811 + tool = uipath_escalation_tool( + "ExpenseApproval", + input_schema={ + "type": "object", + "properties": { + "Amount": {"type": "number"}, + "Receipt": {"$ref": "#/definitions/job-attachment"}, + }, + "required": ["Amount", "Receipt"], + }, + args_from_input={"Receipt": "receipt"}, + ) + agent_input = {"receipt": INVOICE} + expected = {"Amount": 120, "Receipt": INVOICE} + answer = {"action": "approve", "data": {}} + with simulating(mockito(returns(tool.name.lower(), answer, kwargs=expected))): + served = await serve([tool], attachments=[INVOICE], agent_input=agent_input) + args = {"Amount": 120, "Receipt": {"ID": UNKNOWN_ID}} + result = await served.call(tool.name, args) + assert result["isError"] is False, _result_text(result) + # A real start's task data is the same merge. + assert tool.build_data(args, agent_input) == expected + # No agent-input value: the model's unknown ID is refused. + with simulating(mockito(returns(tool.name.lower(), answer))): + served = await serve([tool], attachments=[INVOICE], agent_input={}) + result = await served.call(tool.name, args) + assert result["isError"] is True + assert _result_text(result) == not_found_message(UNKNOWN_ID) + + +@pytest.mark.parametrize( + ("resource", "tool_name", "mock_name", "doc", "answer"), + [ + ( + DEEP_RAG_RESOURCE, + "Deep_RAG_v2", + "Deep RAG (v2)", + DOC, + {"text": "Due in May.", "citations": [], "deepRagId": "d-1"}, + ), + ( + BATCH_RESOURCE, + "Batch_Transform", + "Batch Transform", + CSV, + {"result": {"ID": RESULT_ID, "FullName": "o.csv", "MimeType": "text/csv"}}, + ), + ], +) +async def test_mocked_attachment_tools_input_wins_over_an_unknown_model_id( + serve, # noqa: F811 + resource, + tool_name, + mock_name, + doc, + answer, +): + builder = ( + deep_rag_tool_from_resource + if resource is DEEP_RAG_RESOURCE + else batch_transform_tool_from_resource + ) + tool = builder(_with_attachment_argument(resource, "attachment")) + merged = {"query": "When?", "attachment": doc} + args = {"query": "When?", "attachment": {"ID": UNKNOWN_ID}} + with simulating(mockito(returns(mock_name, answer, kwargs=merged))): + served = await serve([tool], attachments=[doc], agent_input={"document": doc}) + result = await served.call(tool_name, args) + assert result["isError"] is False, _result_text(result) + assert json.loads(_result_text(result)) == answer + assert with_fixed_args(args, tool.fixed_args, {"document": doc}) == merged + with simulating(mockito(returns(mock_name, answer, kwargs=merged))): + served = await serve([tool], attachments=[doc], agent_input={}) + result = await served.call(tool_name, args) + assert result["isError"] is True + assert _result_text(result) == not_found_message(UNKNOWN_ID) diff --git a/packages/uipath-codex-sdk/tests/test_process_tools.py b/packages/uipath-codex-sdk/tests/test_process_tools.py new file mode 100644 index 00000000..f37ae74a --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_process_tools.py @@ -0,0 +1,569 @@ +"""process_tool_from_resource and the pre-filled arguments (argumentProperties).""" + +from __future__ import annotations + +import copy +import logging +from typing import Any + +import pytest + +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + DurableToolContext, + UiPathDurableTool, + UiPathDurableToolError, + process_tool_from_resource, +) +from uipath_codex_sdk._fixed_args import ( + FixedArg, + dedupe_parents, + fixed_args_schema, + input_name, + with_fixed_args, +) + +from .test_tools import _Client, _job + +AGENT_INPUT = {"customer": {"id": "C1", "name": "Ada"}, "region": "EU"} + +#: The models report's example: one property of each supported variant. +INVOICE: dict[str, Any] = { + "$resourceType": "tool", + "type": "process", + "name": "Create Invoice", + "description": "Creates an invoice in the ERP.", + "isEnabled": True, + "inputSchema": { + "type": "object", + "properties": { + "customerId": {"type": "string"}, + "apiKey": {"type": "string"}, + "memo": {"type": "string"}, + "currency": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "amount": {"type": "number"}, + }, + "required": ["customerId", "amount", "currency", "apiKey"], + }, + "outputSchema": {"type": "object", "properties": {"invoiceId": {"type": "string"}}}, + "properties": { + "processName": "CreateInvoice", + "folderPath": "Shared/Finance", + "exampleCalls": [], + }, + "settings": {"timeout": 600, "maxAttempts": 1, "retryDelay": 0}, + "arguments": {}, + "argumentProperties": { + "$['currency']": {"variant": "static", "value": "EUR", "isSensitive": False}, + "$['apiKey']": {"variant": "static", "value": "s3cr3t", "isSensitive": True}, + "$['customerId']": { + "variant": "argument", + "argumentPath": "$.customer.id", + "isSensitive": False, + }, + "$['memo']": { + "variant": "textBuilder", + "isSensitive": False, + "tokens": [ + {"type": "simpleText", "rawString": "Invoice for "}, + {"type": "variable", "rawString": "input.customer.name"}, + ], + }, + "$['tags']": {"variant": "arrayBuilder"}, + "$['tags'][0]": {"variant": "static", "value": "agent", "isSensitive": False}, + "$['tags'][1]": { + "variant": "argument", + "argumentPath": "$.region", + "isSensitive": False, + }, + }, +} + + +def _resource(**changes: Any) -> dict[str, Any]: + data = copy.deepcopy(INVOICE) + data.update(changes) + return data + + +def _static(value: Any) -> dict[str, Any]: + return {"variant": "static", "value": value, "isSensitive": False} + + +def _argument(path: str) -> dict[str, Any]: + return {"variant": "argument", "argumentPath": path, "isSensitive": False} + + +@pytest.fixture +def client(monkeypatch): + holder: dict[str, _Client] = {"c": _Client(job=_job("Pending", FolderKey="fk"))} + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: holder["c"]) + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Env/Folder") + return holder + + +def _ctx(agent_input: dict[str, Any] | None = None) -> DurableToolContext: + return DurableToolContext( + call_id="call_1", runtime_id="rt", tool_name="t", agent_input=agent_input + ) + + +# -------------------------------------------------------------------------- +# the resource +# -------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("type_", "kind", "tool_type", "child"), + [ + ("process", "process", "Process", True), + ("agent", "agent", "Agent", True), + ("api", "process", "Api", True), + ("processorchestration", "process", "agenticProcess", True), + ("flow", "process", "Integration", False), # low-code's fallback + ("function", "process", "Function", True), + ], +) +def test_from_resource_shape_per_type(type_, kind, tool_type, child): + resource = _resource( + type=type_, + name="Basic RPA Process", + argumentProperties={}, + outputSchema={"type": "object", "properties": {}}, + ) + resource["properties"]["exampleCalls"] = [ + {"id": "e1", "input": "{}", "output": "{}"} + ] + tool = process_tool_from_resource(resource) + assert isinstance(tool, UiPathDurableTool) + assert tool.name == "Basic_RPA_Process" + assert tool.process_name == "CreateInvoice" + assert (tool.kind, tool.tool_type) == (kind, tool_type) + assert tool_type == tools_mod.tool_type_of(tool) + assert tool.mock_child_span is child + assert tool.folder_path is None # the run's folder, as in low-code + assert tool.mock_name == "Basic RPA Process" + assert tool.mock_spec.name == "Basic RPA Process" + assert tool.display_name == "CreateInvoice" + assert len(tool.example_calls) == 1 + assert tool.description == "Creates an invoice in the ERP." + assert tool.output_json_schema is None # EMPTY_SCHEMA: any object + assert tool.fixed_args == {} + assert tool.input_schema == INVOICE["inputSchema"] + + +def test_from_resource_description_and_overrides(): + tool = process_tool_from_resource( + _resource(description=""), + tool_name="invoice!", + run_as_me=True, + folder_path="Finance/Override", + ) + assert tool.name == "invoice_" + assert tool.run_as_me is True + assert tool.folder_path == "Finance/Override" + assert tool.description.startswith( + "Runs the UiPath process 'CreateInvoice' and returns its output." + ) + assert '"invoiceId"' in tool.description + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"isEnabled": False}, "is disabled"), + ({"properties": {"folderPath": "x"}}, "has no 'processName'"), + ({"type": "integration"}, "Not a valid process tool resource"), + ( + {"argumentProperties": {"x": {"variant": "dynamic", "isSensitive": False}}}, + "Not a valid process tool resource", + ), + ( + { + "argumentProperties": { + "$['address']": {"variant": "objectBuilder"}, + "$['address']['city']": _argument("$.city"), + } + }, + "argument property type is unsupported", + ), + ], +) +def test_from_resource_refusals(changes, message): + with pytest.raises(ValueError, match=message) as info: + process_tool_from_resource(_resource(**changes)) + if "objectBuilder" in str(changes): + assert "$['address']" in str(info.value) + + +# -------------------------------------------------------------------------- +# argumentProperties +# -------------------------------------------------------------------------- +async def test_argument_properties_each_variant(client): + tool = process_tool_from_resource(INVOICE) + assert set(tool.fixed_args) == {"currency", "apiKey", "customerId", "memo", "tags"} + schema = tool.input_schema + # static, text-builder and array-builder fields are hidden ... + assert set(schema["properties"]) == {"customerId", "amount"} + # ... the agent-input one is visible but optional, with a note + assert schema["required"] == ["amount"] + assert ( + "agent input 'customer.id'" in schema["properties"]["customerId"]["description"] + ) + assert schema["properties"]["amount"] == {"type": "number"} + assert tool.input_json_schema == INVOICE["inputSchema"] # untouched + assert tool.mock_spec.input_schema == schema + + args = tool.validate_args({"amount": 10}) + await tool.start(args, _ctx(AGENT_INPUT)) + [call] = client["c"].calls + assert call["input_arguments"] == { + "amount": 10, + "currency": "EUR", + "apiKey": "s3cr3t", + "customerId": "C1", + "memo": "Invoice for Ada", + "tags": ["agent", "EU"], + } + assert call["folder_path"] == "Env/Folder" # not the resource's folderPath + assert args == {"amount": 10} # kept as is for finish / resume + + +async def test_unresolved_argument_keeps_model_value(client): + tool = process_tool_from_resource(INVOICE) + await tool.start({"amount": 1, "customerId": "from-model"}, _ctx({})) + sent = client["c"].calls[0]["input_arguments"] + assert sent["customerId"] == "from-model" + # an unresolved text variable stays raw, an unresolved array item is null + assert sent["memo"] == "Invoice for input.customer.name" + assert sent["tags"] == ["agent", None] + # ... and the agent input's value wins when it has one + await tool.start({"amount": 1, "customerId": "from-model"}, _ctx(AGENT_INPUT)) + assert client["c"].calls[1]["input_arguments"]["customerId"] == "C1" + + +@pytest.mark.parametrize( + ("key", "name"), + [ + ("$['a']['b']", "a.b"), + ("$['d'][0]", "d[0]"), + ("$['x'][*]['y']", "x[*].y"), + ('$["q"]', "q"), + ("$.a.b", "a.b"), + ("$.a", "a"), + ("a.b", "a.b"), + ("items[*]", "items[*]"), + ("$filter", "$filter"), # a field name, not the JSONPath root + ("$['a.b']", None), # a dotted property has no Codex name + ("$..a", None), + ("$['a'", None), + ("", None), + ], +) +def test_input_name(key, name): + assert input_name(key) == name + + +async def test_jsonpath_keys_normalized(client): + resource = _resource( + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "object", "properties": {"b": {"type": "string"}}}, + "d": {"type": "array", "items": {"type": "string"}}, + "x": { + "type": "array", + "items": { + "type": "object", + "properties": {"y": {"type": "integer"}}, + }, + }, + "top": {"type": "string"}, + }, + }, + argumentProperties={ + "$['a']['b']": _static("ab"), + "$['d'][0]": _static("d0"), + "$['x'][*]['y']": _static(7), + "$.top": _argument("$['customer']['name']"), + }, + ) + tool = process_tool_from_resource(resource) + assert set(tool.fixed_args) == {"a.b", "d[0]", "x[*].y", "top"} + schema = tool.input_schema + assert schema["properties"]["a"]["properties"] == {} + assert "y" not in schema["properties"]["x"]["items"]["properties"] + assert "d" in schema["properties"] # d[0] stays visible, still merged + await tool.start({"x": [{"y": 1}, {}], "d": ["m", "n"]}, _ctx(AGENT_INPUT)) + assert client["c"].calls[0]["input_arguments"] == { + "a": {"b": "ab"}, + "d": ["d0", "n"], + "x": [{"y": 7}, {"y": 7}], + "top": "Ada", + } + + +def test_parent_binding_wins_segment_wise(caplog): + assert dedupe_parents(["user", "user.name", "users", "users[0]", "u"]) == { + "user", + "users", + "u", + } + resource = _resource( + inputSchema={ + "type": "object", + "properties": { + "user": {"type": "object", "properties": {"name": {"type": "string"}}}, + "users": {"type": "array", "items": {"type": "string"}}, + }, + }, + argumentProperties={ + "$['user']": _static({"name": "alice"}), + "$['user']['name']": _static("bob"), + "$['users']": _static(["x"]), + }, + ) + tool = process_tool_from_resource(resource) + assert set(tool.fixed_args) == {"user", "users"} + assert tool.fixed_args["user"].value == {"name": "alice"} + + +def test_array_builder(): + props = { + "$['files']": {"variant": "arrayBuilder"}, + "$['files'][0]": _static("first"), + "$['files'][2]": _argument("$.region"), + "$['files'][3]": { + "variant": "textBuilder", + "isSensitive": True, + "tokens": [ + {"type": "simpleText", "rawString": "Hello, "}, + {"type": "variable", "rawString": "input.customer.name"}, + ], + }, + "$['files'][4]": _argument("$.missing"), + "$['names']": {"variant": "arrayBuilder"}, + "$['names'][0]['name']": _static("ignored"), # not a direct item + "$['names'][1]": _static("kept"), + "$['empty']": {"variant": "arrayBuilder"}, + } + tool = process_tool_from_resource( + _resource( + inputSchema={"type": "object", "properties": {}}, # permissive + argumentProperties=props, + ) + ) + assert set(tool.fixed_args) == {"files", "names", "empty"} + merged = with_fixed_args({}, tool.fixed_args, AGENT_INPUT) + assert merged == { + "files": ["first", None, "EU", "Hello, Ada", None], + "names": [None, "kept"], + "empty": [], + } + + +async def test_star_on_missing_array_becomes_list(client): + resource = _resource( + inputSchema={ + "type": "object", + "properties": { + "items": {"type": "array", "items": {"type": "string"}}, + "q": {"type": "string"}, + }, + "required": ["items", "q"], + }, + argumentProperties={"$['items'][*]": _static("v")}, + ) + tool = process_tool_from_resource(resource) + schema = tool.input_schema + assert "items" not in schema["properties"] and schema["required"] == ["q"] + await tool.start({"q": "x"}, _ctx()) + assert client["c"].calls[0]["input_arguments"] == {"q": "x", "items": ["v"]} + assert with_fixed_args({"items": ["a", "b"]}, tool.fixed_args, None) == { + "items": ["v", "v"] + } + + +def test_unreachable_path_dropped_with_warning(caplog): + props = {"$['nope']": _static(1), "$['currency']": _static("EUR")} + with caplog.at_level(logging.WARNING): + tool = process_tool_from_resource(_resource(argumentProperties=props)) + assert set(tool.fixed_args) == {"currency"} + assert "'nope' is not in the tool's input schema" in caplog.text + # a permissive schema keeps every path, as langchain without args_schema + permissive = process_tool_from_resource( + _resource(inputSchema={"type": "object"}, argumentProperties=props) + ) + assert set(permissive.fixed_args) == {"nope", "currency"} + + +def test_unsupported_keys_are_left_to_the_model(caplog): + props = {"$['a.b']": _static(1), "$['currency']": _argument("$..deep")} + with caplog.at_level(logging.WARNING): + tool = process_tool_from_resource(_resource(argumentProperties=props)) + assert tool.fixed_args == {} + assert "is not a supported path" in caplog.text + assert "not a supported agent input path" in caplog.text + + +async def test_fixed_attachment_from_agent_input_is_linked(client): + attachment = { + "ID": "8e7a7b3c-5f6a-4a43-9f8c-3b8e1f2a9d10", + "FullName": "invoice.pdf", + "MimeType": "application/pdf", + } + resource = _resource( + inputSchema={ + "type": "object", + "properties": { + "document": {"$ref": "#/definitions/job-attachment"}, + "note": {"type": "string"}, + }, + "required": ["document"], + "definitions": { + "job-attachment": { + "type": "object", + "x-uipath-resource-kind": "JobAttachment", + "properties": {"ID": {"type": "string"}}, + } + }, + }, + argumentProperties={"$['document']": _argument("$.invoice")}, + ) + tool = process_tool_from_resource(resource) + await tool.start({"note": "n"}, _ctx({"invoice": attachment})) + [call] = client["c"].calls + assert call["input_arguments"]["document"] == attachment + [linked] = call["attachments"] + assert str(linked.id) == attachment["ID"] + + +def test_span_and_mock_args_show_the_merged_input(): + tool = process_tool_from_resource(INVOICE) + ctx = _ctx(AGENT_INPUT) + merged = tool.span_args({"amount": 10}, ctx) + assert merged["apiKey"] == "s3cr3t" # clear text, as in low-code + assert tool.mock_args({"amount": 10}, ctx) == merged + + +def test_fixed_args_schema_hides_star_arrays_and_notes_inputs(): + schema = { + "type": "object", + "properties": { + "a": {"type": "array", "items": {"type": "string"}}, + "b": {"type": "string", "description": "B."}, + }, + "required": ["a", "b"], + } + out = fixed_args_schema( + schema, {"a[*]": FixedArg(value="v"), "b": FixedArg(path="x")} + ) + assert list(out["properties"]) == ["b"] + assert out["required"] == [] + assert out["properties"]["b"]["description"].startswith("B. (Set from the agent") + assert fixed_args_schema(schema, {}) is schema + + +def test_invalid_merged_input_is_a_tool_error(): + from pydantic import BaseModel + + from uipath_codex_sdk import uipath_process_tool + + class In(BaseModel): + n: int + tag: str + + tool = uipath_process_tool("p", input_schema=In, static_args={"tag": "x"}) + with pytest.raises(UiPathDurableToolError, match="Invalid arguments for tool 'p'"): + tool.merged_args({"n": "not a number"}, None) + + +def test_from_resource_refuses_another_parsed_tool_type(): + from pydantic import TypeAdapter + from uipath.agent.models.agent import AgentResourceConfig + + from .test_context_tools import POLICY_RESOURCE + + resource = TypeAdapter(AgentResourceConfig).validate_python(POLICY_RESOURCE) + with pytest.raises(ValueError, match="Not a process tool resource"): + process_tool_from_resource(resource) # type: ignore[arg-type] # deliberately wrong type + + +@pytest.mark.parametrize( + ("props", "kept"), + [ + # a static parent covers it (langchain's parent-wins dedup skips it) + ( + { + "$['a']": _static({"b": "1"}), + "$['a']['b']": {"variant": "objectBuilder"}, + }, + "a", + ), + # deeper than a direct item of an array builder + ( + { + "$['x']": {"variant": "arrayBuilder"}, + "$['x'][0]": _static({"y": 1}), + "$['x'][0]['y']": {"variant": "objectBuilder"}, + }, + "x", + ), + ], +) +def test_covered_object_builder_is_skipped(props, kept): + tool = process_tool_from_resource( + _resource(inputSchema={"type": "object"}, argumentProperties=props) + ) + assert set(tool.fixed_args) == {kept} + + +@pytest.mark.parametrize( + "props", + [ + { + "$['x']": {"variant": "arrayBuilder"}, + "$['x'][0]": {"variant": "objectBuilder"}, + }, + { + "$['x']": {"variant": "arrayBuilder"}, + "$['x'][1]": {"variant": "arrayBuilder"}, + "$['x'][1][0]": {"variant": "objectBuilder"}, + }, + ], +) +def test_object_builder_array_item_is_refused(props): + # langchain resolves an array builder's direct items, an object builder + # among them (also in a nested array builder), and fails on it. + with pytest.raises(ValueError, match="argument property type is unsupported"): + process_tool_from_resource( + _resource(inputSchema={"type": "object"}, argumentProperties=props) + ) + + +def test_unresolved_required_argument_is_left_to_the_model(): + tool = process_tool_from_resource(INVOICE) + assert "customerId" not in tool.input_schema["required"] + with pytest.raises(UiPathDurableToolError, match="required argument.*customerId"): + tool.merged_args(tool.validate_args({"amount": 1}), {}) + merged = tool.merged_args({"amount": 1, "customerId": "M"}, {}) + assert merged["customerId"] == "M" + + +def test_span_args_are_the_validated_job_input(): + from pydantic import BaseModel + + from uipath_codex_sdk import uipath_process_tool + + class In(BaseModel): + amount: float + currency: str + items: list[str] = [] + + tool = uipath_process_tool("p", input_schema=In, static_args={"currency": "EUR"}) + ctx = _ctx() + args = tool.validate_args({"amount": "2"}) + expected = {"amount": 2.0, "currency": "EUR", "items": []} + assert tool.merged_args(args, None) == expected + assert tool.span_args(args, ctx) == expected + # an input that does not validate shows the unvalidated merge + assert tool.span_args({"amount": "x"}, ctx) == {"amount": "x", "currency": "EUR"} diff --git a/packages/uipath-codex-sdk/tests/test_resume.py b/packages/uipath-codex-sdk/tests/test_resume.py new file mode 100644 index 00000000..54acc08a --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_resume.py @@ -0,0 +1,260 @@ +"""Resume input handling, re-suspension and session guards. + +These paths never start codex: a resume that still has pending calls returns +SUSPENDED straight away. They run through the real ``UiPathResumableRuntime`` +and ``UiPathResumeTriggerHandler`` with fake Orchestrator jobs. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from uipath.core.triggers import UiPathResumeTriggerName +from uipath.platform.common import WaitJobRaw +from uipath.platform.orchestrator import Job +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import ( + UiPathExecuteOptions, + UiPathResumableRuntime, + UiPathRuntimeResult, + UiPathRuntimeStatus, +) + +import uipath_codex_sdk.runtime.runtime as runtime_mod +from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError +from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime, normalize_resume_input +from uipath_codex_sdk.runtime.session_store import ( + STATE_STARTED, + CodexSessionStore, + DanglingCall, + EntrypointRecord, + PendingCall, + ThreadRecord, + encode_rollout, +) +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + +from .durable.fakes import FakeOrchestrator + +RT = "job-key-1" +THREAD = "01a11c0c-5728-7780-816f-157c0652c4b0" + + +def test_normalize_resume_input_shapes(): + one, two = ["c1"], ["c1", "c2"] + assert normalize_resume_input(None, one) == {} + assert normalize_resume_input({}, one) == {} + assert normalize_resume_input({"c1": "x", "zz": 1}, two) == {"c1": "x"} + assert normalize_resume_input({"c1": 1, "c2": 2}, two) == {"c1": 1, "c2": 2} + # a raw payload answers the single pending call... + assert normalize_resume_input({"sum": 3}, one) == {"c1": {"sum": 3}} + assert normalize_resume_input("text", one) == {"c1": "text"} + # ...and is ambiguous (ignored) with several + assert normalize_resume_input({"sum": 3}, two) == {} + assert normalize_resume_input({"sum": 3}, []) == {} + + +@pytest.fixture +def world(monkeypatch) -> FakeOrchestrator: + w = FakeOrchestrator() + import uipath.platform.resume_triggers._protocol as protocol + + import uipath_codex_sdk.tools as tools + + monkeypatch.setattr(tools, "_uipath_client", w.client) + monkeypatch.setattr(protocol, "UiPath", w.client) + monkeypatch.setattr(runtime_mod, "RESUSPEND_BACKOFF_SEC", 0.0) + return w + + +@pytest.fixture +async def storage(tmp_path): + s = SqliteResumableStorage(str(tmp_path / "state.db")) + yield s + await s.dispose() + + +def _agent() -> UiPathCodexAgent: + return UiPathCodexAgent( + model="m", prompt="p", tools=[uipath_process_tool("a_plus_b")] + ) + + +def _job(world: FakeOrchestrator, n: int) -> Job: + key = f"job-{n}" + job = Job.model_validate( + {"Id": n, "Key": key, "State": "Running", "FolderKey": "fk"} + ) + world.jobs[key] = job + return job + + +async def _suspended_state( + storage, world, call_ids: list[str], entrypoint: str = "agent" +) -> UiPathResumableRuntime: + """State as left by a suspension: session records + JOB_RAW triggers.""" + store = CodexSessionStore(storage, RT) + await store.set_entrypoint(EntrypointRecord(entrypoint)) + await store.set_thread(ThreadRecord(THREAD, "sessions/r.jsonl")) + await store.set_rollout(encode_rollout("sessions/r.jsonl", b"{}\n")) + output = {} + for i, cid in enumerate(call_ids, start=1): + value = WaitJobRaw(job=_job(world, i), process_folder_key="fk") + await store.put_pending( + PendingCall( + cid, "a_plus_b", {"a": i}, STATE_STARTED, "WaitJobRaw", value, i + ) + ) + output[cid] = value + await store.set_dangling( + [DanglingCall(c, "function_call", durable=True) for c in call_ids] + ) + wrapper = _wrapper(storage) + await wrapper._handle_suspension( + UiPathRuntimeResult(output=output, status=UiPathRuntimeStatus.SUSPENDED) + ) + return wrapper + + +def _wrapper(storage, agent: UiPathCodexAgent | None = None, entrypoint: str = "agent"): + delegate = UiPathCodexRuntime( + agent=agent or _agent(), + runtime_id=RT, + entrypoint=entrypoint, + file_path="/tmp/main.py", + storage=storage, + ) + return UiPathResumableRuntime( + delegate=delegate, + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id=RT, + ) + + +async def test_resume_while_job_running_resuspends_without_duplicates(storage, world): + await _suspended_state(storage, world, ["c1"]) + result = await _wrapper(storage).execute(None, UiPathExecuteOptions(resume=True)) + assert result.status == UiPathRuntimeStatus.SUSPENDED + triggers = await storage.get_triggers(RT) + assert [t.interrupt_id for t in triggers] == ["c1"] + + +async def test_explicit_empty_resume_with_single_trigger(storage, world): + """``--resume '{}'``: the wrapper deletes the only trigger and passes {}; + the re-suspension must mint a fresh *JOB_RAW* trigger (typed value).""" + await _suspended_state(storage, world, ["c1"]) + result = await _wrapper(storage).execute({}, UiPathExecuteOptions(resume=True)) + assert result.status == UiPathRuntimeStatus.SUSPENDED + [trigger] = await storage.get_triggers(RT) + assert trigger.interrupt_id == "c1" + assert trigger.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert trigger.item_key == "job-1" + assert isinstance(result.output["c1"], WaitJobRaw) + + +async def test_partial_resume_keeps_waiting_on_the_rest(storage, world): + await _suspended_state(storage, world, ["c1", "c2"]) + world.finish("job-1", "Successful", {"sum": 1}) + result = await _wrapper(storage).execute(None, UiPathExecuteOptions(resume=True)) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert list(result.output) == ["c2"] + assert [t.interrupt_id for t in await storage.get_triggers(RT)] == ["c2"] + store = CodexSessionStore(storage, RT) + assert await store.get_completed() == {"c1": json.dumps({"sum": 1})} + assert list(await store.get_pending()) == ["c2"] + + +@pytest.mark.parametrize("state", ["Stopping", "Terminating"]) +async def test_stopping_job_ends_the_resume_loop(storage, world, monkeypatch, state): + """read_trigger reports Stopping/Terminating as fired. Through the real + wrapper loop the call must be answered (not re-suspended), or the wrapper + re-reads the fresh trigger and polls Orchestrator forever.""" + await _suspended_state(storage, world, ["c1"]) + world.finish("job-1", state) + plans: list = [] + + async def fake_run(self, prompt, tools, plan, **kwargs): + plans.append(plan) + yield UiPathRuntimeResult( + output={"output": "done"}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + monkeypatch.setattr(UiPathCodexRuntime, "_run", fake_run) + result = await asyncio.wait_for( + _wrapper(storage).execute(None, UiPathExecuteOptions(resume=True)), 5 + ) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert world.retrieve_calls <= 2 + [plan] = plans + [item] = plan.items + assert item["call_id"] == "c1" + assert json.loads(item["output"]) == {"status": state.lower(), "job_key": "job-1"} + + +async def test_custom_pending_finish_resuspends(storage, world, monkeypatch): + """A custom tool whose finish says "still pending" keeps the call waiting.""" + from uipath_codex_sdk import UiPathDurableToolPending + + await _suspended_state(storage, world, ["c1"]) + world.finish("job-1", "Successful", {"sum": 1}) + agent = _agent() + tool = agent.tools[0] # type: ignore[index] + + async def pending_finish(args, value): + raise UiPathDurableToolPending("not yet") + + monkeypatch.setattr(tool, "finish", pending_finish) + delegate = _wrapper(storage, agent).delegate + result = await delegate.execute( + {"c1": world.jobs["job-1"]}, UiPathExecuteOptions(resume=True) + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert list(result.output) == ["c1"] + assert list(await CodexSessionStore(storage, RT).get_pending()) == ["c1"] + + +async def test_resume_without_session(storage, world): + with pytest.raises(UiPathCodexRuntimeError) as exc: + await _wrapper(storage).execute(None, UiPathExecuteOptions(resume=True)) + assert "SESSION_NOT_FOUND" in str(exc.value.error_info.code) + + +async def test_resume_on_other_entrypoint(storage, world): + await _suspended_state(storage, world, ["c1"], entrypoint="other") + with pytest.raises(UiPathCodexRuntimeError) as exc: + await _wrapper(storage).execute(None, UiPathExecuteOptions(resume=True)) + assert "ENTRYPOINT_MISMATCH" in str(exc.value.error_info.code) + + +async def test_resume_with_unknown_tool(storage, world): + await _suspended_state(storage, world, ["c1"]) + world.finish("job-1", "Successful", {"sum": 1}) + agent = UiPathCodexAgent( + model="m", prompt="p", tools=[uipath_process_tool("other")] + ) + with pytest.raises(UiPathCodexRuntimeError) as exc: + await _wrapper(storage, agent).execute(None, UiPathExecuteOptions(resume=True)) + assert "TOOL_NOT_FOUND" in str(exc.value.error_info.code) + + +async def test_fresh_run_drops_stale_triggers(storage, world, monkeypatch): + """A fresh run over a kept state file must not inherit old triggers.""" + await _suspended_state(storage, world, ["old_call"]) + assert await storage.get_triggers(RT) + delegate = _wrapper(storage).delegate + + async def no_codex(self, prompt, tools, plan, **kwargs): + yield None # pragma: no cover - only the bookkeeping before it matters + + monkeypatch.setattr(UiPathCodexRuntime, "_run", no_codex) + async for _ in delegate.stream({}, None): + break + assert await storage.get_triggers(RT) is None + store = CodexSessionStore(storage, RT) + assert await store.get_pending() == {} + assert await store.get_thread() is None + assert await store.get_entrypoint() == EntrypointRecord("agent") diff --git a/packages/uipath-codex-sdk/tests/test_schema.py b/packages/uipath-codex-sdk/tests/test_schema.py new file mode 100644 index 00000000..07ad7dfc --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_schema.py @@ -0,0 +1,203 @@ +"""Entrypoint schema: runtime graph, settings metadata, prompt-derived input.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from pydantic import BaseModel +from uipath._cli.cli_init import write_mermaid_files + +from uipath_codex_sdk import StdioMcpServer, UiPathCodexAgent, uipath_process_tool +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError +from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime +from uipath_codex_sdk.runtime.schema import ( + get_agent_graph, + get_entrypoint_schema, + prompt_fields, +) + +TEMPLATE = ( + Path(__file__).parents[1] + / "src" + / "uipath_codex_sdk" + / "_cli" + / "_templates" + / "main.py.template" +) + + +def _runtime(agent: UiPathCodexAgent) -> UiPathCodexRuntime: + return UiPathCodexRuntime( + agent=agent, runtime_id="rt", entrypoint="agent", file_path="/tmp/main.py" + ) + + +def _edges(agent: UiPathCodexAgent) -> set[tuple[str, str, str | None]]: + return {(e.source, e.target, e.label) for e in get_agent_graph(agent).edges} + + +def test_graph_read_only_agent_lists_shell_only(): + graph = get_agent_graph(UiPathCodexAgent(model="m", name="joker")) + nodes = {n.id: n for n in graph.nodes} + + assert list(nodes) == ["__start__", "agent", "tools", "__end__"] + assert nodes["agent"].type == "model" + assert nodes["agent"].name == "joker" + assert nodes["agent"].metadata == {"model_name": "m"} + assert nodes["tools"].type == "tool" + assert nodes["tools"].metadata == { + "tool_names": ["shell"], + "tool_count": 1, + "mcp_servers": [], + "builtin_tools": ["shell"], + } + assert _edges(UiPathCodexAgent(model="m")) == { + ("__start__", "agent", "input"), + ("agent", "tools", None), + ("tools", "agent", None), + ("agent", "__end__", "output"), + } + + +def test_graph_tools_node_aggregates_durable_mcp_and_builtins(): + agent = UiPathCodexAgent( + model="m", + sandbox="full-access", + tools=[uipath_process_tool("Invoice Approval", tool_name="approve")], + mcp_servers={"orders": StdioMcpServer(command="python")}, + ) + tools = next(n for n in get_agent_graph(agent).nodes if n.id == "tools") + + assert tools.metadata == { + "tool_names": ["approve", "orders", "shell", "apply_patch"], + "tool_count": 4, + "mcp_servers": ["orders"], + "builtin_tools": ["shell", "apply_patch"], + } + + +def test_schema_carries_graph_and_model_settings(): + schema = get_entrypoint_schema(UiPathCodexAgent(model="gpt-x"), "agent") + + assert schema.metadata == {"settings": {"model": "gpt-x"}} + dumped = json.loads(schema.model_dump_json(by_alias=True)) + assert [n["id"] for n in dumped["graph"]["nodes"]] == [ + "__start__", + "agent", + "tools", + "__end__", + ] + + +@pytest.mark.parametrize( + ("prompt", "expected"), + [ + ("Tell me a joke about {topic}.", ["topic"]), + ("{a} and {b} and {a!r}", ["a", "b"]), + ("{order.id} / {items[0]} / {order.total}", ["order", "items"]), + ("{} {0} {{literal}}", []), + ("{value:{width}}", ["value", "width"]), + ("broken {", []), + ("no placeholders", []), + ], +) +def test_prompt_fields(prompt: str, expected: list[str]): + assert prompt_fields(prompt) == expected + + +def test_input_schema_derived_from_prompt_placeholders(): + agent = UiPathCodexAgent(model="m", prompt="Summarize {doc} for {audience}.") + schema = get_entrypoint_schema(agent, "agent") + + assert schema.input == { + "type": "object", + "properties": {"doc": {"type": "string"}, "audience": {"type": "string"}}, + "required": ["doc", "audience"], + } + rendered = _runtime(agent)._render_prompt({"doc": "d", "audience": "kids"}) + assert rendered == "Summarize d for kids." + with pytest.raises(UiPathCodexRuntimeError): + _runtime(agent)._render_prompt({"doc": "d"}) + + +def test_structured_placeholders_are_not_typed_as_strings(): + agent = UiPathCodexAgent( + model="m", prompt="Ship {order[id]} to {city}, gift: {order}." + ) + schema = get_entrypoint_schema(agent, "agent") + + assert schema.input == { + "type": "object", + "properties": {"order": {}, "city": {"type": "string"}}, + "required": ["order", "city"], + } + runtime = _runtime(agent) + order = {"id": "A1"} + assert runtime._render_prompt({"order": order, "city": "Oslo"}) == ( + f"Ship A1 to Oslo, gift: {order}." + ) + + +@pytest.mark.parametrize( + ("prompt", "fields"), + [ + ("Ship {order[id]}", {"order": "A123"}), + ("Ship to {order.city}", {"order": {"city": "Oslo"}}), + ("First {items[3]}", {"items": ["a"]}), + ], +) +def test_input_that_does_not_fit_the_template_is_input_invalid( + prompt: str, fields: dict +): + with pytest.raises(UiPathCodexRuntimeError) as err: + _runtime(UiPathCodexAgent(model="m", prompt=prompt))._render_prompt(fields) + assert "INPUT_INVALID" in str(err.value.error_info.code) + + +def test_input_schema_permissive_without_placeholders(): + for prompt in (None, "Say hi.", "positional {}"): + schema = get_entrypoint_schema(UiPathCodexAgent(model="m", prompt=prompt), "a") + assert schema.input == { + "type": "object", + "properties": {}, + "additionalProperties": True, + } + + +def test_declared_input_schema_wins_over_placeholders(): + class In(BaseModel): + topic: str + count: int = 1 + + agent = UiPathCodexAgent(model="m", prompt="{topic}", input_schema=In) + schema = get_entrypoint_schema(agent, "agent") + + assert schema.input["properties"]["count"]["type"] == "integer" + assert schema.input["required"] == ["topic"] + + +def test_scaffold_template_declares_input_schema(): + namespace: dict[str, object] = {} + exec(compile(TEMPLATE.read_text(), str(TEMPLATE), "exec"), namespace) + agent = namespace["agent"] + assert isinstance(agent, UiPathCodexAgent) + + schema = get_entrypoint_schema(agent, "agent") + assert schema.input["required"] == ["topic"] + assert schema.input["properties"]["topic"]["type"] == "string" + assert _runtime(agent)._render_prompt({"topic": "robots"}).endswith("about robots.") + + +def test_uipath_init_writes_mermaid_for_graph(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + agent = UiPathCodexAgent(model="m", tools=[uipath_process_tool("p")]) + + paths = write_mermaid_files([get_entrypoint_schema(agent, "agent")]) + + assert [os.path.basename(p) for p in paths] == ["agent.mermaid"] + text = Path(paths[0]).read_text() + for node_id in ("__start__", "agent", "tools", "__end__"): + assert node_id in text diff --git a/packages/uipath-codex-sdk/tests/test_session_store.py b/packages/uipath-codex-sdk/tests/test_session_store.py new file mode 100644 index 00000000..7b2db376 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_session_store.py @@ -0,0 +1,223 @@ +"""CodexSessionStore and rollout helpers.""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +from pathlib import Path + +from uipath.platform.common import WaitJobRaw +from uipath.platform.orchestrator import Job + +from uipath_codex_sdk.runtime.session_store import ( + STATE_STARTED, + CodexSessionStore, + DanglingCall, + EntrypointRecord, + PendingCall, + ThreadRecord, + cut_torn_tail, + dangling_calls, + encode_rollout, + find_rollout, + find_rollouts, + has_call, + parse_rollout, + write_rollout, +) +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + +THREAD = "01a11c0c-5728-7780-816f-157c0652c4b0" + + +def _line(payload: dict) -> str: + return json.dumps({"type": "response_item", "payload": payload}) + "\n" + + +def _rollout(*payloads: dict) -> bytes: + head = json.dumps({"type": "session_meta", "payload": {"id": THREAD}}) + "\n" + return (head + "".join(_line(p) for p in payloads)).encode() + + +def test_cut_torn_tail(): + assert cut_torn_tail(b'{"a":1}\n{"b":2}\n') == b'{"a":1}\n{"b":2}\n' + assert cut_torn_tail(b'{"a":1}\n{"b":') == b'{"a":1}\n' + assert cut_torn_tail(b'{"b":') == b"" + assert cut_torn_tail(b"") == b"" + + +def test_dangling_calls_in_order_with_types(): + data = _rollout( + {"type": "message", "role": "user", "content": []}, + {"type": "function_call", "call_id": "c1", "name": "a"}, + {"type": "function_call_output", "call_id": "c1", "output": "ok"}, + {"type": "function_call", "call_id": "c2", "name": "durable"}, + {"type": "custom_tool_call", "call_id": "c3", "name": "apply_patch"}, + {"type": "function_call", "call_id": "c4", "name": "exec_command"}, + {"type": "custom_tool_call_output", "call_id": "c3", "output": "done"}, + ) + lines = parse_rollout(data + b'{"torn') + assert dangling_calls(lines) == [("c2", "function_call"), ("c4", "function_call")] + assert has_call(lines, "c2") and not has_call(lines, "nope") + d = DanglingCall("c3", "custom_tool_call") + assert d.output_type == "custom_tool_call_output" + assert DanglingCall("c2", "function_call").output_type == "function_call_output" + + +def test_rollout_encode_and_restore_in_place(tmp_path: Path): + home = tmp_path / "home" + rel = f"sessions/2026/10/08/rollout-2026-10-08T10-00-00-{THREAD}.jsonl" + data = _rollout({"type": "function_call", "call_id": "c1", "name": "x"}) + record = encode_rollout(rel, data) + assert record.encoding == "zlib+b64" and record.raw_size == len(data) + assert record.content() == data + + # fresh CODEX_HOME: written back at its original relative path + target = write_rollout(home, THREAD, record) + assert target == home / rel and target.read_bytes() == data + assert find_rollout(home, THREAD) == target + + # an existing copy elsewhere is replaced in place, never duplicated + home2 = tmp_path / "home2" + other = ( + home2 / "sessions/2026/10/09" / f"rollout-2026-10-09T00-00-00-{THREAD}.jsonl" + ) + other.parent.mkdir(parents=True) + other.write_bytes(b"old\n") + target2 = write_rollout(home2, THREAD, record) + assert target2 == other + assert other.read_bytes() == data + assert find_rollouts(home2, THREAD) == [other] + + +def test_zst_rollouts_are_stored_raw(): + record = encode_rollout("sessions/x/rollout-a.jsonl.zst", b"\x28\xb5\x2f\xfd") + assert record.encoding == "raw+b64" + assert record.content() == b"\x28\xb5\x2f\xfd" + + +def _wait_job_raw(key: str = "job-1") -> WaitJobRaw: + job = Job.model_validate( + {"Id": 1, "Key": key, "State": "Running", "FolderKey": "fk"} + ) + return WaitJobRaw(job=job, process_folder_key="fk") + + +async def test_store_round_trip(tmp_path: Path): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + try: + store = CodexSessionStore(storage, "rt") + await store.set_thread(ThreadRecord(THREAD, "sessions/a.jsonl")) + await store.set_entrypoint(EntrypointRecord("agent")) + await store.set_rollout(encode_rollout("sessions/a.jsonl", b"x\n")) + value = _wait_job_raw() + await store.put_pending( + PendingCall( + "c1", + "a_plus_b", + {"a": 1}, + STATE_STARTED, + "WaitJobRaw", + value, + 1, + span={"span_id": "00f067aa0ba902b7", "attributes": {"a": 1}}, + ) + ) + await store.set_trace({"span_id": "b7ad6b7169203331", "name": "Agent run"}) + await store.set_completed({"c0": "out"}) + await store.set_dangling([DanglingCall("c1", "function_call", durable=True)]) + + # a second store (the resumed process) sees everything + other = CodexSessionStore(storage, "rt") + assert await other.get_thread() == ThreadRecord(THREAD, "sessions/a.jsonl") + assert await other.get_entrypoint() == EntrypointRecord("agent") + rollout = await other.get_rollout() + assert rollout is not None and rollout.content() == b"x\n" + pending = await other.get_pending() + rebuilt = pending["c1"].suspend_value() + # a typed model again: a dict would become an API trigger + assert isinstance(rebuilt, WaitJobRaw) + assert rebuilt.job.key == "job-1" and rebuilt.process_folder_key == "fk" + assert pending["c1"].span == { + "span_id": "00f067aa0ba902b7", + "attributes": {"a": 1}, + } + saved_trace = await other.get_trace() + assert saved_trace is not None + assert saved_trace["span_id"] == "b7ad6b7169203331" + assert await other.get_completed() == {"c0": "out"} + assert [d.call_id for d in await other.get_dangling()] == ["c1"] + # scoped by runtime id + assert await CodexSessionStore(storage, "other").get_thread() is None + + await other.clear_suspension() + assert await other.get_pending() == {} + assert await other.get_completed() == {} + assert await other.get_dangling() == [] + assert await other.get_thread() is not None + # the open Agent run outlives a suspension's bookkeeping + assert await other.get_trace() is not None + await other.clear_all() + assert await other.get_thread() is None and await other.get_rollout() is None + assert await other.get_trace() is None + finally: + await storage.dispose() + + +async def test_concurrent_pending_writes_do_not_lose_calls(tmp_path: Path): + storage = SqliteResumableStorage(str(tmp_path / "state.db")) + try: + store = CodexSessionStore(storage, "rt") + await asyncio.gather( + *( + store.put_pending(PendingCall(f"c{i}", "t", {}, order=i)) + for i in range(10) + ) + ) + assert sorted(await store.get_pending()) == sorted(f"c{i}" for i in range(10)) + await store.remove_pending(["c1", "c2"]) + assert "c1" not in await store.get_pending() + finally: + await storage.dispose() + + +def test_find_rollout_prefers_newest(tmp_path: Path): + a = tmp_path / "sessions/2026/10/01" / f"rollout-x-{THREAD}.jsonl" + b = tmp_path / "sessions/2026/10/02" / f"rollout-y-{THREAD}.jsonl" + for p in (a, b): + p.parent.mkdir(parents=True) + p.write_text("{}\n") + old = time.time() - 100 + os.utime(a, (old, old)) + assert find_rollout(tmp_path, THREAD) == b + assert find_rollout(tmp_path, "missing") is None + + +def test_leftover_restore_tmp_is_never_the_rollout(tmp_path: Path): + """A crash between the temp write and the rename leaves ``.restore-tmp`` + behind; it must not be picked as the live rollout, and the next restore + removes it.""" + live = tmp_path / "sessions/2026/10/01" / f"rollout-x-{THREAD}.jsonl" + live.parent.mkdir(parents=True) + live.write_text("{}\n") + old = time.time() - 100 + os.utime(live, (old, old)) + tmp = live.with_name(live.name + ".restore-tmp") + tmp.write_text('{"partial": true}\n') # newer than the live rollout + zst = live.with_name(f"rollout-z-{THREAD}.jsonl.zst") + zst.write_bytes(b"") + os.utime(zst, (old - 10, old - 10)) + assert find_rollouts(tmp_path, THREAD) == sorted([live, zst]) + assert find_rollout(tmp_path, THREAD) == live + zst.unlink() + + target = write_rollout( + tmp_path, + THREAD, + encode_rollout(f"sessions/2026/10/01/{live.name}", _rollout({"a": 1})), + ) + assert target == live + assert not tmp.exists() + assert parse_rollout(live.read_bytes()) == parse_rollout(_rollout({"a": 1})) diff --git a/packages/uipath-codex-sdk/tests/test_storage.py b/packages/uipath-codex-sdk/tests/test_storage.py new file mode 100644 index 00000000..2aa0d9f6 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_storage.py @@ -0,0 +1,115 @@ +"""SqliteResumableStorage: langchain-compatible schema and round-trips.""" + +from __future__ import annotations + +import sqlite3 + +import pytest +from pydantic import BaseModel +from uipath.core.triggers import ( + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) + +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + + +def _job_raw_trigger(interrupt_id: str, job_key: str) -> UiPathResumeTrigger: + trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.JOB, + trigger_name=UiPathResumeTriggerName.JOB_RAW, + item_key=job_key, + folder_key="folder-1", + payload={"job": {"Key": job_key, "Id": 1}, "process_folder_key": "folder-1"}, + ) + trigger.interrupt_id = interrupt_id + return trigger + + +@pytest.fixture +async def storage(tmp_path): + s = SqliteResumableStorage(str(tmp_path / "rt" / "state.db")) + yield s + await s.dispose() + + +async def test_job_raw_name_and_payload_survive_round_trip(storage): + await storage.save_triggers("r1", [_job_raw_trigger("call_a", "job-a")]) + [loaded] = await storage.get_triggers("r1") + assert loaded.interrupt_id == "call_a" + assert loaded.trigger_type == UiPathResumeTriggerType.JOB + # both are exclude=True on the model; dropping them would turn a JOB_RAW + # trigger into a plain JOB one (which raises on a faulted job) + assert loaded.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert loaded.payload == { + "job": {"Key": "job-a", "Id": 1}, + "process_folder_key": "folder-1", + } + assert loaded.item_key == "job-a" + assert loaded.folder_key == "folder-1" + + +async def test_delete_by_interrupt_id(storage): + a, b = _job_raw_trigger("call_a", "job-a"), _job_raw_trigger("call_b", "job-b") + await storage.save_triggers("r1", [a, b]) + # a re-read copy differs from what was saved (payload/name re-added), and + # must still delete: matching is by interrupt id, not by serialized JSON + [loaded_a, _] = await storage.get_triggers("r1") + await storage.delete_triggers("r1", [loaded_a]) + remaining = await storage.get_triggers("r1") + assert [t.interrupt_id for t in remaining] == ["call_b"] + await storage.delete_trigger("r1", remaining[0]) + assert await storage.get_triggers("r1") is None + + +async def test_save_replaces_and_is_scoped_by_runtime(storage): + await storage.save_triggers("r1", [_job_raw_trigger("call_a", "job-a")]) + await storage.save_triggers("r2", [_job_raw_trigger("call_x", "job-x")]) + await storage.save_triggers("r1", [_job_raw_trigger("call_b", "job-b")]) + assert [t.interrupt_id for t in await storage.get_triggers("r1")] == ["call_b"] + assert [t.interrupt_id for t in await storage.get_triggers("r2")] == ["call_x"] + + +async def test_order_is_insertion_order(storage): + ids = [f"call_{i}" for i in range(5)] + await storage.save_triggers("r1", [_job_raw_trigger(i, f"job-{i}") for i in ids]) + assert [t.interrupt_id for t in await storage.get_triggers("r1")] == ids + + +class _Model(BaseModel): + x: int + + +async def test_kv_round_trip(storage): + await storage.set_value("r1", "ns", "s", "text") + await storage.set_value("r1", "ns", "d", {"a": [1, 2]}) + await storage.set_value("r1", "ns", "m", _Model(x=3)) + await storage.set_value("r1", "ns", "n", None) + assert await storage.get_value("r1", "ns", "s") == "text" + assert await storage.get_value("r1", "ns", "d") == {"a": [1, 2]} + assert await storage.get_value("r1", "ns", "m") == {"x": 3} + assert await storage.get_value("r1", "ns", "n") is None + assert await storage.get_value("r1", "ns", "missing") is None + assert await storage.get_value("r2", "ns", "s") is None + await storage.set_value("r1", "ns", "s", "updated") + assert await storage.get_value("r1", "ns", "s") == "updated" + with pytest.raises(TypeError): + await storage.set_value("r1", "ns", "bad", 42) + + +async def test_schema_matches_langchain(storage, tmp_path): + await storage.save_triggers("r1", [_job_raw_trigger("call_a", "job-a")]) + await storage.dispose() + conn = sqlite3.connect(str(tmp_path / "rt" / "state.db")) + try: + cols = [ + r[1] for r in conn.execute("PRAGMA table_info(__uipath_resume_triggers)") + ] + assert cols == ["id", "runtime_id", "interrupt_id", "data", "timestamp"] + kv = [r[1] for r in conn.execute("PRAGMA table_info(__uipath_runtime_kv)")] + assert kv == ["runtime_id", "namespace", "key", "value", "timestamp"] + mode = conn.execute("PRAGMA journal_mode").fetchone()[0] + assert mode == "wal" + finally: + conn.close() diff --git a/packages/uipath-codex-sdk/tests/test_telemetry.py b/packages/uipath-codex-sdk/tests/test_telemetry.py new file mode 100644 index 00000000..742fd163 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_telemetry.py @@ -0,0 +1,337 @@ +"""Unit tests of the span helpers.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from uipath_codex_sdk.runtime.telemetry import ( + ItemSpans, + SseCompletedReader, + complete_span, + complete_tool_call, + has_output_schema, + item_tool, + item_tool_result, + item_tool_text, + latest_input_text, + response_summary, + restored_span, + span_attributes, + span_record, + start_tool_call, +) + +from ._typing import attrs, end_ns, parent_span_id, start_ns + + +def _sse(*events: dict[str, Any]) -> bytes: + return b"".join( + f"event: {e['type']}\r\ndata: {json.dumps(e)}\r\n\r\n".encode() for e in events + ) + + +def test_sse_reader_takes_the_completed_response_across_chunk_boundaries(): + final = {"id": "r1", "output": [{"type": "message", "content": []}]} + data = _sse( + {"type": "response.created", "response": {"id": "r1"}}, + {"type": "response.output_text.delta", "delta": "hel"}, + {"type": "response.completed", "response": final}, + ) + reader = SseCompletedReader() + for i in range(0, len(data), 7): + reader.feed(data[i : i + 7]) + assert reader.response == final + + +def test_sse_reader_fills_output_from_item_events(): + call = {"type": "function_call", "name": "add", "arguments": "{}", "call_id": "c1"} + reader = SseCompletedReader() + reader.feed( + _sse( + {"type": "response.output_item.done", "item": call}, + {"type": "response.completed", "response": {"id": "r1", "usage": {}}}, + ) + ) + assert reader.response == {"id": "r1", "usage": {}, "output": [call]} + assert SseCompletedReader().response is None + + +def test_response_summary(): + summary = response_summary( + { + "output": [ + {"type": "reasoning", "summary": []}, + { + "type": "message", + "content": [{"type": "output_text", "text": "hi"}], + }, + { + "type": "function_call", + "call_id": "c1", + "name": "a_plus_b", + "arguments": '{"a": 1}', + }, + ], + "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}, + } + ) + assert summary == { + "text": "hi", + "toolCalls": [{"id": "c1", "name": "a_plus_b", "arguments": '{"a": 1}'}], + "usage": {"promptTokens": 5, "completionTokens": 2, "totalTokens": 7}, + } + + +def test_response_summary_drops_schema_placeholders_next_to_tool_calls(): + placeholder = { + "type": "message", + "content": [{"type": "output_text", "text": '{"issue_key": "pending"}'}], + } + call = {"type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}"} + payload = {"text": {"format": {"type": "json_schema", "schema": {}}}} + assert has_output_schema(payload) + assert not has_output_schema({"text": {"verbosity": "low"}}) + assert not has_output_schema({}) + + with_calls = {"output": [placeholder, call]} + assert response_summary(with_calls, schema_output=True)["text"] == "" + assert response_summary(with_calls)["text"] == '{"issue_key": "pending"}' + final = {"output": [placeholder]} + assert response_summary(final, schema_output=True)["text"] == ( + '{"issue_key": "pending"}' + ) + + +def test_latest_input_text_prefers_the_newest_item_with_text(): + payload = { + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "q"}], + }, + {"type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "c1", "output": "42"}, + ] + } + assert latest_input_text(payload) == "42" + assert latest_input_text({"input": "plain"}) == "plain" + assert latest_input_text({}) == "" + + +def test_item_tool_names_codex_items(): + mcp = { + "type": "mcpToolCall", + "server": "orders", + "tool": "get", + "arguments": {"id": 1}, + } + assert item_tool(mcp) == ("get", "Mcp", {"id": 1}) + assert item_tool({"type": "commandExecution", "command": "ls"}) == ( + "shell", + "Internal", + {"command": "ls"}, + ) + assert item_tool({"type": "agentMessage"}) is None + failed = {**mcp, "status": "failed", "error": {"message": "boom"}} + assert item_tool_result(failed) == (None, "boom") + + +def test_span_attributes_json_encodes_structures_and_drops_none(monkeypatch): + monkeypatch.setenv("UIPATH_PROCESS_VERSION", "1.2.3") + attributes = span_attributes("toolCall", input={"a": 1}, missing=None, flag=True) + assert attributes["span_type"] == attributes["type"] == "toolCall" + assert attributes["uipath.custom_instrumentation"] is True + assert attributes["agentVersion"] == "1.2.3" + assert attributes["input"] == '{"a": 1}' and attributes["flag"] is True + assert "missing" not in attributes + + +def test_saved_span_is_completed_with_its_identity(): + """``span_record`` → JSON → ``complete_span``: same ids, start and parent, + through the provider's processors like an ended span.""" + exporter = InMemorySpanExporter() + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + processor = SimpleSpanProcessor(exporter) + trace.get_tracer_provider().add_span_processor(processor) # type: ignore[attr-defined] + try: + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("parent") as parent: + open_span = tracer.start_span( + "Tool call - t", attributes=span_attributes("toolCall", tags=["x"]) + ) + record = json.loads(json.dumps(span_record(open_span))) + assert record["parent_span_id"] == format( + parent.get_span_context().span_id, "016x" + ) + assert record["attributes"]["tags"] == '["x"]' + + # A child of the restored span joins the saved trace (sampled). + with tracer.start_as_current_span( + "child", context=trace.set_span_in_context(restored_span(record)) + ) as child: + assert child.is_recording() + + complete_span(record, attributes={"output": "42"}, error="job faulted") + finished = {s.name: s for s in exporter.get_finished_spans()} + done = finished["Tool call - t"] + assert done.context.span_id == open_span.get_span_context().span_id + assert done.context.trace_id == open_span.get_span_context().trace_id + assert parent_span_id(done) == parent.get_span_context().span_id + assert isinstance(open_span, ReadableSpan) + assert start_ns(done) == open_span.start_time + assert end_ns(done) >= start_ns(done) + assert attrs(done)["output"] == "42" + assert attrs(done)["span_type"] == "toolCall" + assert attrs(done)["error"] == "job faulted" + assert done.status.status_code.name == "ERROR" + assert parent_span_id(finished["child"]) == done.context.span_id + assert done.to_json() # what the --trace-file exporter writes + finally: + exporter.shutdown() + + +@pytest.mark.parametrize("error", [RuntimeError(), asyncio.CancelledError()]) +async def test_a_messageless_error_still_faults_the_agent_run(error): + """``CancelledError()`` has no text: the run still completes with ERROR, + and without the output saved at suspension.""" + from uipath_codex_sdk.runtime.factory import _AgentRun + + exporter = InMemorySpanExporter() + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + try: + tracer = trace.get_tracer("test") + live = tracer.start_span( + "Agent run - a", + attributes=span_attributes("agentRun", output={"x": 1}, status="suspended"), + ) + record = json.loads(json.dumps(span_record(live))) + run = _AgentRun(restored_span(record), store=None, delegate=None, saved=record) + await run.fail(error) + run.detach() + [done] = exporter.get_finished_spans() + assert done.context.span_id == live.get_span_context().span_id + assert done.status.status_code.name == "ERROR" + assert attrs(done)["status"] == "faulted" + assert attrs(done)["error"] == type(error).__name__ + assert "output" not in attrs(done) + finally: + exporter.shutdown() + + +def _exporter() -> InMemorySpanExporter: + exporter = InMemorySpanExporter() + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined] + return exporter + + +def _mcp_item(**extra: Any) -> dict[str, Any]: + return { + "id": "item-1", + "type": "mcpToolCall", + "server": "orders", + "tool": "get_order_status", + "arguments": {"order_id": "A-1"}, + "status": "completed", + **extra, + } + + +def test_item_tool_text_of_codex_items(): + result = { + "content": [ + {"type": "text", "text": "shipped"}, + {"type": "image", "data": "..."}, + {"type": "text", "text": "eta 2d"}, + ], + "structured_content": None, + } + assert item_tool_text(_mcp_item(result=result)) == "shipped\neta 2d" + assert item_tool_text(_mcp_item(result={"content": []})) is None + shell = {"type": "commandExecution", "aggregated_output": "a.txt\n"} + assert item_tool_text(shell) == "a.txt\n" + changes = [ + {"path": "a.py", "kind": {"type": "add"}, "diff": "+x"}, + {"path": "b.py", "kind": {"type": "update", "move_path": "c.py"}}, + ] + assert ( + item_tool_text({"type": "fileChange", "changes": changes}) + == "add a.py\nupdate b.py -> c.py" + ) + assert item_tool_text({"type": "webSearch", "query": "uipath"}) == "uipath" + dynamic = {"type": "dynamicToolCall", "content_items": [{"text": "42"}]} + assert item_tool_text(dynamic) == "42" + + +def test_tool_spans_feed_the_eval_helpers_and_the_llmops_mapping(): + """Spans built as the runtime builds them read back through ``uipath.eval`` + (names, args, ``output.value``'s content) and the LLMOps exporter.""" + from uipath.eval._helpers.evaluators_helpers import ( + extract_tool_calls, + extract_tool_calls_names, + extract_tool_calls_outputs, + ) + from uipath.tracing._otel_exporters import LlmOpsHttpExporter + + exporter = _exporter() + try: + items = ItemSpans() + item = _mcp_item() + items.start(item) + result = {"content": [{"type": "text", "text": "shipped"}]} + items.finish({**item, "result": result}) + shell = {"id": "item-2", "type": "commandExecution", "command": "ls"} + items.start(shell) + items.finish({**shell, "aggregated_output": "a.txt", "exit_code": 0}) + durable = start_tool_call( + "a_plus_b", tool_type="Process", call_id="c1", arguments={"a": 1} + ) + record = span_record(durable) + assert record is not None + complete_tool_call(record, output={"sum": 42}) + spans = list(exporter.get_finished_spans()) + finally: + exporter.shutdown() + + names = ["get_order_status", "shell", "a_plus_b"] + assert [s.name for s in spans] == [f"Tool call - {n}" for n in names] + assert extract_tool_calls_names(spans) == names + calls = extract_tool_calls(spans) + assert [c.args for c in calls] == [{"order_id": "A-1"}, {"command": "ls"}, {"a": 1}] + outputs = extract_tool_calls_outputs(spans) + assert [o.output for o in outputs] == ["shipped", "a.txt", '{"sum": 42}'] + mcp = attrs(spans[0]) + assert mcp["tool.server"] == mcp["mcp.server"] == "orders" + assert json.loads(mcp["output"]) == result + + mapped = LlmOpsHttpExporter._map_tool_call_attributes( + object.__new__(LlmOpsHttpExporter), dict(mcp) + ) + assert mapped["toolName"] == "get_order_status" + assert mapped["arguments"] == {"order_id": "A-1"} + assert mapped["result"] == result + durable_attrs = dict(attrs(spans[2])) + assert LlmOpsHttpExporter._map_tool_call_attributes( + object.__new__(LlmOpsHttpExporter), durable_attrs + )["result"] == {"sum": 42} diff --git a/packages/uipath-codex-sdk/tests/test_tool_host.py b/packages/uipath-codex-sdk/tests/test_tool_host.py new file mode 100644 index 00000000..2ff30ef3 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_tool_host.py @@ -0,0 +1,760 @@ +"""DurableToolHost: the MCP endpoint durable tools are served from.""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +from typing import Any + +import pytest +from aiohttp import ClientSession, ClientTimeout, web +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from uipath_codex_sdk import ( + DurableToolContext, + UiPathDurableToolError, + UiPathToolError, + uipath_process_tool, +) +from uipath_codex_sdk.runtime.session_store import STATE_STARTED, CodexSessionStore +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage +from uipath_codex_sdk.runtime.tool_host import ( + LONG_RUNNING_TAG, + UNKNOWN_OUTCOME_OUTPUT, + DurableToolHost, +) + +from ._typing import attrs + +REQUEST_VAR: contextvars.ContextVar[str] = contextvars.ContextVar( + "request_var", default="unset" +) + + +class FakeTool: + name = "a_plus_b" + description = "Adds a and b." + input_schema = { + "type": "object", + "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + } + + def __init__( + self, error: Exception | None = None, gate: asyncio.Event | None = None + ) -> None: + self.error = error + self.gate = gate + self.started: list[tuple[dict[str, Any], DurableToolContext, str]] = [] + self.spans: list[Any] = [] + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + return uipath_process_tool("x", input_schema=self.input_schema).validate_args( + args + ) + + async def start(self, args: dict[str, Any], ctx: DurableToolContext) -> Any: + self.started.append((args, ctx, REQUEST_VAR.get())) + self.spans.append(trace.get_current_span()) + if self.gate is not None: + await self.gate.wait() + if self.error is not None: + raise self.error + from uipath.platform.common import WaitJobRaw + from uipath.platform.orchestrator import Job + + job = Job.model_validate( + {"Id": 1, "Key": "job-1", "State": "Pending", "FolderKey": "fk"} + ) + return WaitJobRaw(job=job, process_folder_key="fk") + + async def finish(self, args: dict[str, Any], resume_value: Any) -> str: + return "done" + + +class Harness: + def __init__( + self, host: DurableToolHost, url: str, store: CodexSessionStore + ) -> None: + self.host, self.url, self.store = host, url, store + self._id = 0 + + async def rpc( + self, + method: str, + params: dict[str, Any] | None = None, + *, + token: str | None = None, + timeout: float = 10, + notify: bool = False, + ) -> tuple[int, Any]: + self._id += 1 + msg: dict[str, Any] = { + "jsonrpc": "2.0", + "method": method, + "params": params or {}, + } + if not notify: + msg["id"] = self._id + headers = {"Authorization": f"Bearer {token or self.host.token}"} + async with ClientSession(timeout=ClientTimeout(total=timeout)) as s: + async with s.post(self.url, json=msg, headers=headers) as r: + body = await r.json() if r.content_type == "application/json" else None + return r.status, body + + async def call(self, args: dict[str, Any], call_id: str | None = "call_1", **kw): + params: dict[str, Any] = {"name": "a_plus_b", "arguments": args} + if call_id is not None: + params["_meta"] = {"callId": call_id} + return await self.rpc("tools/call", params, **kw) + + +@pytest.fixture +async def make_harness(tmp_path): + cleanups: list[Any] = [] + + async def make( + tool: FakeTool, + probe_result: bool = True, + tag: bool = True, + tracer: trace.Tracer | None = None, + ) -> Harness: + storage = SqliteResumableStorage(str(tmp_path / f"s{len(cleanups)}.db")) + store = CodexSessionStore(storage, "rt") + REQUEST_VAR.set("from-run-context") + host = DurableToolHost( + [tool], + store=store, + runtime_id="rt", + context=contextvars.copy_context(), + tag_descriptions=tag, + tracer=tracer, + ) + REQUEST_VAR.set("unset") + + async def probe(call_id: str) -> bool: + return probe_result + + host.bind(probe) + app = web.Application() + app.router.add_route("*", "/mcp", host.handle) + runner = web.AppRunner(app, shutdown_timeout=1) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + cleanups.append((host, runner, storage)) + return Harness(host, f"http://127.0.0.1:{port}/mcp", store) + + yield make + for host, runner, storage in cleanups: + await host.release_all() + await runner.cleanup() + await storage.dispose() + + +async def test_protocol_basics(make_harness): + h = await make_harness(FakeTool()) + async with ClientSession() as s: + async with s.get(h.url) as r: + assert r.status == 405 + status, _ = await h.rpc("tools/list", token="wrong") + assert status == 401 + status, body = await h.rpc("initialize", {"protocolVersion": "2025-03-26"}) + assert status == 200 + assert body["result"]["protocolVersion"] == "2025-03-26" + assert "tools" in body["result"]["capabilities"] + status, _ = await h.rpc("notifications/initialized", notify=True) + assert status == 202 + status, body = await h.rpc("tools/list") + [tool] = body["result"]["tools"] + assert tool["name"] == "a_plus_b" + assert tool["description"] == f"{LONG_RUNNING_TAG} Adds a and b." + assert tool["inputSchema"]["required"] == ["a", "b"] + status, body = await h.rpc("ping") + assert body["result"] == {} + status, body = await h.rpc("resources/list") + assert body["error"]["code"] == -32601 + + +async def test_untagged_descriptions(make_harness): + h = await make_harness(FakeTool(), tag=False) + _, body = await h.rpc("tools/list") + assert body["result"]["tools"][0]["description"] == "Adds a and b." + + +async def test_call_is_held_and_recorded(make_harness): + tool = FakeTool() + h = await make_harness(tool) + task = asyncio.create_task(h.call({"a": 1, "b": 2})) + for _ in range(100): + if h.host.held: + break + await asyncio.sleep(0.02) + assert list(h.host.held) == ["call_1"] + assert h.host.starting_count == 0 + assert not task.done() # never answered + [(args, ctx, seen_var)] = tool.started + assert args == {"a": 1, "b": 2} + assert ctx.call_id == "call_1" and ctx.runtime_id == "rt" + # started inside the context captured when the run began + assert seen_var == "from-run-context" + pending = await h.store.get_pending() + assert pending["call_1"].state == STATE_STARTED + assert pending["call_1"].value_type == "WaitJobRaw" + assert pending["call_1"].suspend_value().job.key == "job-1" + await h.host.release_all() + status, body = await asyncio.wait_for(task, 5) + assert body["result"]["isError"] is True + + +@pytest.mark.parametrize( + ("args", "call_id", "expected"), + [ + ({"a": 1}, "call_1", "Invalid arguments"), + ({"a": 1, "b": 2}, None, "no call id"), + ], +) +async def test_rejected_calls_answer_immediately(make_harness, args, call_id, expected): + tool = FakeTool() + h = await make_harness(tool) + status, body = await h.call(args, call_id=call_id) + assert body["result"]["isError"] is True + assert expected in body["result"]["content"][0]["text"] + assert not tool.started and not h.host.held + + +async def test_unknown_tool(make_harness): + h = await make_harness(FakeTool()) + _, body = await h.rpc( + "tools/call", {"name": "nope", "arguments": {}, "_meta": {"callId": "c"}} + ) + assert body["result"]["isError"] is True + + +async def test_call_not_in_rollout_is_not_started(make_harness): + tool = FakeTool() + h = await make_harness(tool, probe_result=False) + _, body = await h.call({"a": 1, "b": 2}) + assert body["result"]["isError"] is True + assert "not recorded" in body["result"]["content"][0]["text"] + assert not tool.started + assert await h.store.get_pending() == {} + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (UiPathDurableToolError("Could not find process"), "Could not find process"), + (RuntimeError("kaput"), "failed to start: kaput"), + ], +) +async def test_start_failure_is_reported_to_the_model(make_harness, error, expected): + h = await make_harness(FakeTool(error=error)) + _, body = await h.call({"a": 1, "b": 2}) + assert body["result"]["isError"] is True + assert expected in body["result"]["content"][0]["text"] + assert not h.host.held + assert await h.store.get_pending() == {} # the intent record is removed + + +async def _until(predicate, timeout: float = 5.0) -> None: + for _ in range(int(timeout / 0.02)): + if predicate(): + return + await asyncio.sleep(0.02) + raise AssertionError("condition not reached") + + +async def test_start_runs_under_a_tool_span(make_harness): + """The job's ParentSpanId comes from the current span: the Tool call span. + + A started job leaves the span open; its identity is saved with the call. + """ + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tool = FakeTool() + h = await make_harness(tool, tracer=provider.get_tracer("test")) + task = asyncio.create_task(h.call({"a": 1, "b": 2})) + await _until(lambda: bool(h.host.held)) + assert exporter.get_finished_spans() == () + [current] = tool.spans + assert current.is_recording() + [call] = (await h.store.get_pending()).values() + span = call.span + assert span is not None + assert span["name"] == "Tool call - a_plus_b" + assert span["span_id"] == format(current.get_span_context().span_id, "016x") + assert span["start_time_ns"] == current.start_time + attributes = span["attributes"] + assert attributes["call_id"] == "call_1" + assert attributes["span_type"] == "toolCall" + assert attributes["tool.name"] == "a_plus_b" + assert attributes["input"] == '{"a": 1, "b": 2}' + assert json.loads(attributes["output"]) == { + "status": "started", + "job_key": "job-1", + "job_state": "Pending", + } + await h.host.release_all() + await asyncio.wait_for(task, 5) + + +async def test_failed_start_ends_its_tool_span(make_harness): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tool = FakeTool(error=RuntimeError("boom")) + h = await make_harness(tool, tracer=provider.get_tracer("test")) + status, body = await h.call({"a": 1, "b": 2}) + assert status == 200 and body["result"]["isError"] + [span] = exporter.get_finished_spans() + assert span.name == "Tool call - a_plus_b" + assert span.status.status_code.name == "ERROR" + assert await h.store.get_pending() == {} + + +async def test_duplicate_call_while_starting_starts_one_job(make_harness): + """A retried tools/call for a call id whose start is in flight is held; + it never starts a second job.""" + gate = asyncio.Event() + tool = FakeTool(gate=gate) + h = await make_harness(tool) + first = asyncio.create_task(h.call({"a": 1, "b": 2})) + await _until(lambda: h.host.starting_count == 1) + second = asyncio.create_task(h.call({"a": 1, "b": 2})) + await asyncio.sleep(0.2) + assert len(tool.started) == 1 + gate.set() + await _until(lambda: bool(h.host.held)) + await asyncio.sleep(0.1) + assert len(tool.started) == 1 + assert not first.done() and not second.done() + assert list(await h.store.get_pending()) == ["call_1"] + await h.host.release_all() + await asyncio.wait_for(asyncio.gather(first, second), 5) + + +async def test_call_after_close_starts_nothing(make_harness): + """Once the runtime is stopping codex, a late call is answered at once.""" + tool = FakeTool() + h = await make_harness(tool) + h.host.close() + assert h.host.closing + _, body = await h.call({"a": 1, "b": 2}) + assert body["result"]["isError"] is True + assert "being suspended" in body["result"]["content"][0]["text"] + assert not tool.started and not h.host.held + assert await h.store.get_pending() == {} + + +# -------------------------------------------------------------------------- +# immediate tools (answered right away, never held) +# -------------------------------------------------------------------------- +class FakeImmediate: + """An immediate tool: echoes its arguments, or fails / waits on demand.""" + + kind = "integration" + + def __init__( + self, + name: str = "web_search", + *, + error: BaseException | None = None, + gate: asyncio.Event | None = None, + timeout_sec: float | None = None, + ) -> None: + self.name = name + self.description = "Searches the web." + self.input_schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + } + self.error = error + self.gate = gate + self.timeout_sec = timeout_sec + self.span_attributes = {"connector_key": "uipath-uipath-airdk"} + self.calls: list[tuple[dict[str, Any], Any, str]] = [] + self.spans: list[Any] = [] + self.cancelled = False + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + if not isinstance(args.get("query"), str): + raise ValueError("query must be a string") + return dict(args) + + async def call(self, args: dict[str, Any], ctx: Any) -> str: + self.calls.append((args, ctx, REQUEST_VAR.get())) + self.spans.append(trace.get_current_span()) + try: + if self.gate is not None: + await self.gate.wait() + except asyncio.CancelledError: + self.cancelled = True + raise + if self.error is not None: + raise self.error + return json.dumps({"hits": [args["query"]]}) + + +class HostHarness(Harness): + async def call_tool( + self, name: str, args: dict[str, Any], call_id: str | None = "call_is", **kw + ): + params: dict[str, Any] = {"name": name, "arguments": args} + if call_id is not None: + params["_meta"] = {"callId": call_id} + return await self.rpc("tools/call", params, **kw) + + +@pytest.fixture +async def make_host(tmp_path): + cleanups: list[Any] = [] + + async def make( + tools: list[Any], + *, + with_store: bool = False, + tracer: trace.Tracer | None = None, + agent_input: dict[str, Any] | None = None, + attachments: Any = None, + model: str | None = None, + ) -> HostHarness: + storage = ( + SqliteResumableStorage(str(tmp_path / f"h{len(cleanups)}.db")) + if with_store + else None + ) + store = CodexSessionStore(storage, "rt") if storage is not None else None + REQUEST_VAR.set("from-run-context") + host = DurableToolHost( + tools, + store=store, + runtime_id="rt", + context=contextvars.copy_context(), + tracer=tracer, + agent_input=agent_input, + attachments=attachments, + model=model, + ) + REQUEST_VAR.set("unset") + if with_store: + probes: list[str] = [] + + async def probe(call_id: str) -> bool: + probes.append(call_id) + return True + + host.bind(probe) + host.probes = probes # type: ignore[attr-defined] + app = web.Application() + app.router.add_route("*", "/mcp", host.handle) + runner = web.AppRunner(app, shutdown_timeout=1) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + cleanups.append((host, runner, storage)) + return HostHarness(host, f"http://127.0.0.1:{port}/mcp", store) # type: ignore[arg-type] + + yield make + for host, runner, storage in cleanups: + await host.release_all() + await host.cancel_inflight() + await runner.cleanup() + if storage is not None: + await storage.dispose() + + +def _text(body: dict[str, Any]) -> str: + return body["result"]["content"][0]["text"] + + +async def test_immediate_call_is_answered_without_probe_or_store(make_host): + tool = FakeImmediate() + h = await make_host([tool], agent_input={"topic": "x"}) + status, body = await h.call_tool("web_search", {"query": "codex"}) + assert status == 200 + assert body["result"]["isError"] is False + assert json.loads(_text(body)) == {"hits": ["codex"]} + [(args, ctx, seen_var)] = tool.calls + assert args == {"query": "codex"} + assert ctx.call_id == "call_is" and ctx.runtime_id == "rt" + assert ctx.tool_name == "web_search" and ctx.agent_input == {"topic": "x"} + assert seen_var == "from-run-context" + assert not h.host.held and h.host.inflight_count == 0 + assert h.host.immediate_outcome("call_is") == _text(body) + assert not h.host.has_durable + + +async def test_immediate_call_without_a_call_id(make_host): + tool = FakeImmediate() + h = await make_host([tool]) + _, body = await h.call_tool("web_search", {"query": "q"}, call_id=None) + assert body["result"]["isError"] is False + assert tool.calls[0][1].call_id is None + + +async def test_only_durable_tools_are_tagged_and_calls_do_not_mix(make_host): + durable, immediate = FakeTool(), FakeImmediate() + h = await make_host([durable, immediate], with_store=True) + _, body = await h.rpc("tools/list") + descriptions = {t["name"]: t["description"] for t in body["result"]["tools"]} + assert descriptions == { + "a_plus_b": f"{LONG_RUNNING_TAG} Adds a and b.", + "web_search": "Searches the web.", + } + # One model response: a durable call (held) and an immediate one (answered). + held = asyncio.create_task(h.call({"a": 1, "b": 2})) + await _until(lambda: bool(h.host.held)) + _, body = await h.call_tool("web_search", {"query": "q"}) + assert body["result"]["isError"] is False + assert list(h.host.held) == ["call_1"] + assert h.host.probes == ["call_1"] # type: ignore[attr-defined] + assert list(await h.store.get_pending()) == ["call_1"] + assert not held.done() + await h.host.release_all() + await asyncio.wait_for(held, 5) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + UiPathToolError("Integration service returned an error"), + "Integration service returned an error", + ), + (RuntimeError("kaput"), "Tool 'web_search' failed: kaput"), + ], +) +async def test_immediate_errors_are_is_error_results( + make_host, error, expected, caplog +): + h = await make_host([FakeImmediate(error=error)]) + _, body = await h.call_tool("web_search", {"query": "q"}) + assert body["result"]["isError"] is True + assert _text(body) == expected + if isinstance(error, RuntimeError): + assert "tool web_search failed" in caplog.text + + +async def test_immediate_invalid_arguments(make_host): + tool = FakeImmediate() + h = await make_host([tool]) + _, body = await h.call_tool("web_search", {"query": 1}) + assert body["result"]["isError"] is True + assert "Invalid arguments for tool 'web_search'" in _text(body) + assert not tool.calls + + +def _provider() -> tuple[InMemorySpanExporter, trace.Tracer]: + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return exporter, provider.get_tracer("test") + + +async def test_immediate_timeout_cancels_the_call(make_host): + exporter, tracer = _provider() + tool = FakeImmediate(gate=asyncio.Event(), timeout_sec=0.1) + h = await make_host([tool], tracer=tracer) + _, body = await h.call_tool("web_search", {"query": "q"}) + assert body["result"]["isError"] is True + assert _text(body) == "Tool 'web_search' did not finish within 0.1s." + await _until(lambda: tool.cancelled) + [span] = exporter.get_finished_spans() + assert span.status.status_code.name == "ERROR" + assert attrs(span)["error"] == "interrupted" + assert h.host.inflight_count == 0 + + +async def test_immediate_call_span(make_host): + exporter, tracer = _provider() + tool = FakeImmediate() + with tracer.start_as_current_span("Agent run"): + h = await make_host([tool], tracer=tracer) + _, body = await h.call_tool("web_search", {"query": "q"}) + text = _text(body) + spans = {s.name: s for s in exporter.get_finished_spans()} + run, call = spans["Agent run"], spans["Tool call - web_search"] + assert call.parent is not None and call.parent.span_id == run.context.span_id + call_attrs = attrs(call) + assert call_attrs["span_type"] == "toolCall" + assert ( + call_attrs["toolType"] == "Integration" + and call_attrs["tool.kind"] == "integration" + ) + assert call_attrs["tool.server"] == "uipath_tools" + assert call_attrs["call_id"] == "call_is" + assert call_attrs["connector_key"] == "uipath-uipath-airdk" + assert json.loads(call_attrs["output.value"]) == {"content": text} + assert json.loads(call_attrs["output"]) == {"hits": ["q"]} + assert call.status.status_code.name == "OK" + [current] = tool.spans + assert current.get_span_context().span_id == call.context.span_id + + +async def test_integration_tool_child_span(make_host, monkeypatch): + """The real IS tool: Tool call → integrationTool child (toolName, arguments).""" + import uipath_codex_sdk.tools as tools_mod + from uipath_codex_sdk import uipath_integration_tool + + from .durable.fakes import FakeConnections + + conns = FakeConnections() + conns.responses["/v2/webSearch"] = {"results": []} + monkeypatch.setattr( + tools_mod, "_uipath_client", lambda: type("C", (), {"connections": conns})() + ) + exporter, tracer = _provider() + # The child span uses the package tracer: route it to our exporter. + monkeypatch.setattr("uipath_codex_sdk.runtime.telemetry.get_tracer", lambda: tracer) + tool = uipath_integration_tool( + "Web Search", + tool_name="web_search", + connection_id="conn-1", + connector_key="uipath-uipath-airdk", + tool_path="/v2/webSearch", + input_schema={"type": "object", "properties": {"query": {"type": "string"}}}, + static_args={"provider": "GoogleCustomSearch"}, + ) + h = await make_host([tool], tracer=tracer) + _, body = await h.call_tool("web_search", {"query": "q"}) + assert body["result"]["isError"] is False, body + spans = {s.name: s for s in exporter.get_finished_spans()} + call, child = spans["Tool call - web_search"], spans["Web Search"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert attrs(child)["span_type"] == "integrationTool" + assert attrs(child)["toolName"] == "Web Search" + assert json.loads(attrs(child)["arguments"]) == { + "query": "q", + "provider": "GoogleCustomSearch", + } + assert json.loads(attrs(child)["result"]) == {"results": []} + assert attrs(call)["connection_id"] == "conn-1" + assert attrs(call)["tool.display_name"] == "Web Search" + [inv] = conns.invocations + assert inv.activity_input == {"query": "q", "provider": "GoogleCustomSearch"} + + +async def test_context_tool_child_span(make_host, monkeypatch): + """The real context tool: Tool call → contextGroundingTool RETRIEVER child.""" + from types import SimpleNamespace + + from uipath.eval.evaluators.legacy_context_precision_evaluator import ( + LegacyContextPrecisionEvaluator, + ) + from uipath.platform.context_grounding import UnifiedQueryResult + + import uipath_codex_sdk.tools as tools_mod + from uipath_codex_sdk import uipath_context_tool + + class Search: + async def unified_search_async(self, **kwargs: Any) -> Any: + return UnifiedQueryResult.model_validate( + { + "semanticResults": { + "values": [ + { + "content": "Book travel via the portal.", + "source": "travel.pdf", + "page_number": 1, + "score": 0.9, + } + ], + "metadata": {"operation_id": "op-1", "strategy": "s"}, + } + } + ) + + client = SimpleNamespace(context_grounding=Search(), folders=None) + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: client) + exporter, tracer = _provider() + monkeypatch.setattr("uipath_codex_sdk.runtime.telemetry.get_tracer", lambda: tracer) + tool = uipath_context_tool( + "Company Policies", index_name="company-policies", number_of_results=3 + ) + h = await make_host([tool], tracer=tracer) + _, body = await h.call_tool("Company_Policies", {"query": "travel"}) + assert body["result"]["isError"] is False, body + spans = {s.name: s for s in exporter.get_finished_spans()} + call, child = spans["Tool call - Company_Policies"], spans["Company Policies"] + retriever = spans["ContextGroundingRetriever"] + assert child.parent is not None and child.parent.span_id == call.context.span_id + assert retriever.parent is not None + assert retriever.parent.span_id == child.context.span_id + assert attrs(call)["toolType"] == "ContextGrounding" + assert attrs(call)["tool.kind"] == "context" + assert attrs(call)["index_name"] == "company-policies" + assert attrs(call)["context_retrieval_mode"] == "Semantic" + child_attrs = attrs(child) + assert child_attrs["span_type"] == "contextGroundingTool" + assert ( + child_attrs["retrieval_mode"] == "Semantic" and child_attrs["query"] == "travel" + ) + assert ( + child_attrs["number_of_results"] == 3 and child_attrs["system_index"] is False + ) + docs = json.loads(child_attrs["output"])["documents"] + assert docs[0]["page_content"] == "Book travel via the portal." + # Only the retriever span is an OpenInference span: the LLMOps exporter + # takes SpanType from openinference.span.kind. + for span in (call, child): + assert "openinference.span.kind" not in attrs(span) + assert attrs(retriever)["openinference.span.kind"] == "RETRIEVER" + assert attrs(retriever)["input.value"] == "travel" + assert attrs(retriever)["output.value"] == child_attrs["output"] + # What the context precision evaluator finds in this trace. + evaluator = LegacyContextPrecisionEvaluator.__new__(LegacyContextPrecisionEvaluator) + [grounding] = evaluator._extract_context_groundings(list(spans.values())) + assert grounding["query"] == "travel" and len(grounding["chunks"]) == 1 + + +async def test_immediate_call_after_close_is_refused(make_host): + tool = FakeImmediate() + h = await make_host([tool]) + h.host.close() + _, body = await h.call_tool("web_search", {"query": "q"}) + assert body["result"]["isError"] is True + assert "being suspended" in _text(body) + assert not tool.calls + + +async def test_concurrent_immediate_calls_and_cancel_inflight(make_host): + gate = asyncio.Event() + tool = FakeImmediate(gate=gate) + h = await make_host([tool]) + first = asyncio.create_task(h.call_tool("web_search", {"query": "a"}, "c1")) + second = asyncio.create_task(h.call_tool("web_search", {"query": "b"}, "c2")) + await _until(lambda: h.host.inflight_count == 2) + assert sorted(h.host.inflight_ids) == ["c1", "c2"] + gate.set() + (_, b1), (_, b2) = await asyncio.wait_for(asyncio.gather(first, second), 5) + assert not b1["result"]["isError"] and not b2["result"]["isError"] + assert h.host.inflight_count == 0 + assert json.loads(h.host.immediate_outcome("c2") or "") == {"hits": ["b"]} + + # A call cut off by the suspension: cancelled, outcome unknown. + tool.gate = asyncio.Event() + third = asyncio.create_task(h.call_tool("web_search", {"query": "c"}, "c3")) + await _until(lambda: h.host.inflight_count == 1) + await h.host.cancel_inflight() + assert tool.cancelled + assert h.host.immediate_outcome("c3") == UNKNOWN_OUTCOME_OUTPUT + _, b3 = await asyncio.wait_for(third, 5) + assert b3["result"]["isError"] is True + assert _text(b3) == UNKNOWN_OUTCOME_OUTPUT + assert h.host.inflight_count == 0 + + +def test_durable_tools_need_a_store(): + with pytest.raises(ValueError, match="session store"): + DurableToolHost([FakeTool()], store=None, runtime_id="rt") diff --git a/packages/uipath-codex-sdk/tests/test_tools.py b/packages/uipath-codex-sdk/tests/test_tools.py new file mode 100644 index 00000000..7cd647f3 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_tools.py @@ -0,0 +1,332 @@ +"""uipath_process_tool / uipath_agent_tool: start, finish and validation.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from pydantic import BaseModel +from uipath.platform.common import WaitJobRaw +from uipath.platform.orchestrator import Job + +import uipath_codex_sdk.tools as tools_mod +from uipath_codex_sdk import ( + DurableToolContext, + UiPathDurableTool, + UiPathDurableToolError, + UiPathDurableToolPending, + uipath_agent_tool, + uipath_process_tool, +) +from uipath_codex_sdk.tools import sanitize_tool_name + + +class AddIn(BaseModel): + a: int + b: int + + +class AddOut(BaseModel): + sum: int + + +def _job(state: str, **extra: Any) -> Job: + return Job.model_validate({"Id": 7, "Key": "job-7", "State": state, **extra}) + + +class _Client: + def __init__(self, job: Job | None = None, error: Exception | None = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._job, self._error = job, error + self.processes = SimpleNamespace(invoke_async=self._invoke) + self.jobs = SimpleNamespace(extract_output_async=self._extract) + + async def _invoke(self, **kwargs: Any) -> Job | None: + self.calls.append(kwargs) + if self._error is not None: + raise self._error + return self._job + + async def _extract(self, job: Job) -> str | None: + return job.output_arguments + + +@pytest.fixture +def client(monkeypatch): + holder: dict[str, _Client] = {"c": _Client()} + monkeypatch.setattr(tools_mod, "_uipath_client", lambda: holder["c"]) + return holder + + +CTX = DurableToolContext(call_id="call_1", runtime_id="rt", tool_name="a_plus_b") + + +def test_factories_and_protocol(): + tool = uipath_process_tool( + "a plus b!", input_schema=AddIn, output_schema=AddOut, description="Adds." + ) + assert isinstance(tool, UiPathDurableTool) + assert tool.name == "a_plus_b_" + assert tool.process_name == "a plus b!" + assert tool.kind == "process" + assert tool.input_schema["required"] == ["a", "b"] + assert tool.description.startswith("Adds.") + assert '"sum"' in tool.description # output schema hint + agent = uipath_agent_tool("Research_Agent", tool_name="research") + assert agent.kind == "agent" and agent.name == "research" + assert "agent 'Research_Agent'" in agent.description + assert sanitize_tool_name("x" * 100) == "x" * 64 + + +def test_validate_args_with_model_and_schema(): + tool = uipath_process_tool("p", input_schema=AddIn) + assert tool.validate_args({"a": "1", "b": 2}) == {"a": 1, "b": 2} + with pytest.raises(ValueError): + tool.validate_args({"a": 1}) + schema = { + "type": "object", + "properties": {"q": {"type": "string"}, "n": {"type": "integer"}}, + "required": ["q"], + "additionalProperties": False, + } + raw = uipath_process_tool("p", input_schema=schema) + assert raw.validate_args({"q": "x", "n": 3}) == {"q": "x", "n": 3} + for bad in ({"n": 3}, {"q": 1}, {"q": "x", "n": True}, {"q": "x", "z": 1}): + with pytest.raises(ValueError): + raw.validate_args(bad) + assert uipath_process_tool("p").validate_args({"anything": 1}) == {"anything": 1} + + +async def test_start_returns_wait_job_raw_with_folder_key_only(client, monkeypatch): + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + client["c"] = _Client(job=_job("Pending", FolderKey="fk-1")) + tool = uipath_process_tool("a_plus_b", folder_path="Shared", run_as_me=True) + value = await tool.start({"a": 1, "b": 2}, CTX) + assert isinstance(value, WaitJobRaw) + assert value.job.key == "job-7" + # setting both folder fields breaks the job lookup on every resume + assert value.process_folder_key == "fk-1" and value.process_folder_path is None + [call] = client["c"].calls + assert call == { + "name": "a_plus_b", + "input_arguments": {"a": 1, "b": 2}, + "folder_path": "Shared", + "run_as_me": True, + "parent_span_id": None, # no current span + } + + +async def test_start_parents_the_job_under_the_current_span(client): + # The tool host's Tool call span, not invoke_async's own span. + from opentelemetry.sdk.trace import TracerProvider + + client["c"] = _Client(job=_job("Pending", FolderKey="fk-1")) + tracer = TracerProvider().get_tracer("test") + with tracer.start_as_current_span("Tool call - p") as span: + await uipath_process_tool("p").start({}, CTX) + [call] = client["c"].calls + assert call["parent_span_id"] == format(span.get_span_context().span_id, "016x") + + +async def test_start_without_folder_key_falls_back_to_path(client, monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_PATH", "Env/Folder") + client["c"] = _Client(job=_job("Pending")) + value = await uipath_process_tool("p").start({}, CTX) + assert value.process_folder_key is None + assert value.process_folder_path == "Env/Folder" + assert client["c"].calls[0]["folder_path"] == "Env/Folder" + assert client["c"].calls[0]["run_as_me"] is None + + +class _Enriched(Exception): + def __init__(self, status: int, code: str | None, message: str) -> None: + super().__init__(message) + self.status_code = status + self.error_info = SimpleNamespace(error_code=code, message=message) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + _Enriched(404, "1002", "AssociatedProcessNotFound"), + "Could not find process for tool 'p'", + ), + (_Enriched(400, "1100", "x"), "Could not find folder for tool 'p'"), + (_Enriched(409, None, "busy"), "Cannot start process for tool 'p': busy"), + (RuntimeError("boom"), "Failed to start the job for tool 'p': boom"), + ], +) +async def test_start_errors_are_model_facing(client, error, expected): + client["c"] = _Client(error=error) + with pytest.raises(UiPathDurableToolError) as exc: + await uipath_process_tool("p").start({}, CTX) + assert expected in str(exc.value) + + +async def test_finish_successful(client): + tool = uipath_process_tool("p") + job = _job("Successful", OutputArguments=json.dumps({"sum": 3})) + assert await tool.finish({}, job) == json.dumps({"sum": 3}) + empty = await tool.finish({}, _job("Successful")) + assert json.loads(empty) == {"status": "successful", "job_key": "job-7"} + # a serialized Job (e.g. from a stored payload) is recognised too + as_dict = job.model_dump(by_alias=True) + assert await tool.finish({}, as_dict) == json.dumps({"sum": 3}) + + +async def test_finish_faulted_and_stopped(client): + tool = uipath_process_tool("p") + faulted = json.loads(await tool.finish({}, _job("Faulted", Info="Boom"))) + assert faulted == {"status": "faulted", "error": "Boom", "job_key": "job-7"} + detailed = json.loads( + await tool.finish({}, _job("Faulted", JobError={"Code": "E1", "Title": "Bad"})) + ) + assert detailed["status"] == "faulted" + assert detailed["error"] == "Bad" + assert detailed["details"] == {"code": "E1", "title": "Bad"} + unknown = json.loads(await tool.finish({}, _job("Faulted"))) + assert unknown["error"] == "Unknown error" + stopped = json.loads(await tool.finish({}, _job("Stopped"))) + assert stopped == {"status": "stopped", "job_key": "job-7"} + + +@pytest.mark.parametrize("state", ["Running", "Pending", "Suspended", "Resumed"]) +async def test_finish_not_final_is_pending(client, state): + with pytest.raises(UiPathDurableToolPending): + await uipath_process_tool("p").finish({}, _job(state)) + + +@pytest.mark.parametrize("state", ["Stopping", "Terminating"]) +async def test_finish_stopping_is_final(client, state): + """The trigger handler reports Stopping/Terminating as fired, so they must + be final here too; otherwise the in-process resume loop never ends.""" + result = json.loads(await uipath_process_tool("p").finish({}, _job(state))) + assert result == {"status": state.lower(), "job_key": "job-7"} + + +async def test_finish_raw_payloads(client): + tool = uipath_process_tool("p") + assert await tool.finish({}, {"sum": 7}) == json.dumps({"sum": 7}) + assert await tool.finish({}, "plain text") == "plain text" + assert await tool.finish({}, 42) == "42" + assert await tool.finish({}, None) == "null" + + +# -------------------------------------------------------------------------- +# pre-filled arguments (static_args / args_from_input) +# -------------------------------------------------------------------------- +class Order(BaseModel): + sku: str + quantity: int + channel: str + customer: str + + +@pytest.mark.parametrize("factory", [uipath_process_tool, uipath_agent_tool]) +async def test_static_args_and_args_from_input(client, factory): + client["c"] = _Client(job=_job("Pending", FolderKey="fk-1")) + tool = factory( + "PlaceOrder", + input_schema=Order, + static_args={"channel": "codex"}, + args_from_input={"customer": "{{user.email}}"}, + ) + schema = tool.input_schema + assert "channel" not in schema["properties"] # hidden + assert "agent input 'user.email'" in schema["properties"]["customer"]["description"] + assert schema["required"] == ["sku", "quantity"] # customer is optional now + # the model's arguments validate without the required pre-filled fields + args = tool.validate_args({"sku": "A-1", "quantity": "2"}) + assert args == {"sku": "A-1", "quantity": "2"} + ctx = DurableToolContext( + call_id="c1", + runtime_id="rt", + tool_name=tool.name, + agent_input={"user": {"email": "ana@example.com"}}, + ) + await tool.start(args, ctx) + # the merged input is validated (and coerced) by the model + assert client["c"].calls[0]["input_arguments"] == { + "sku": "A-1", + "quantity": 2, + "channel": "codex", + "customer": "ana@example.com", + } + assert args == {"sku": "A-1", "quantity": "2"} # not changed by start + # an agent input without the value: the model's is used + await tool.start({**args, "customer": "bob"}, CTX) + assert client["c"].calls[1]["input_arguments"]["customer"] == "bob" + # nothing to fill it with: the model gets the validation error + with pytest.raises(UiPathDurableToolError, match="Invalid arguments"): + await tool.start(args, CTX) + + +def test_code_paths_accept_jsonpath_and_nesting(): + schema = { + "type": "object", + "properties": { + "meta": {"type": "object", "properties": {"source": {"type": "string"}}}, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": {"taxed": {"type": "boolean"}}, + }, + }, + }, + } + tool = uipath_process_tool( + "p", + input_schema=schema, + static_args={"$['meta']['source']": "codex", "lines[*].taxed": True}, + args_from_input={"$.meta": "$.request.meta"}, + ) + assert set(tool.fixed_args) == {"meta.source", "lines[*].taxed", "meta"} + assert tool.fixed_args["meta"].path == "request.meta" + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ( + {"static_args": {"a": 1}, "args_from_input": {"a": "x"}}, + "'a' is in both static_args and args_from_input", + ), + ({"static_args": {"zzz": 1}}, "'zzz' is not in its input schema"), + ({"args_from_input": {"a": "$..deep"}}, "is not a supported path"), + ], +) +def test_code_api_refusals(kwargs, message): + with pytest.raises(ValueError, match=message): + uipath_process_tool("p", input_schema=AddIn, **kwargs) + # a permissive schema takes any field + assert uipath_process_tool("p", static_args={"zzz": 1}).fixed_args + + +def test_no_prefilled_args_keeps_the_schema_and_validation(): + tool = uipath_process_tool("p", input_schema=AddIn) + assert tool.input_schema is tool.input_json_schema + assert tool.merged_args({"a": 1, "b": 2}, None) == {"a": 1, "b": 2} + assert tool.span_args({"a": 1}, CTX) == {"a": 1} + with pytest.raises(ValueError): + tool.validate_args({"a": 1}) + + +def test_validate_args_with_prefilled_fields_still_checks_the_model(): + tool = uipath_process_tool( + "PlaceOrder", + input_schema=Order, + static_args={"channel": "codex"}, + args_from_input={"customer": "user.email"}, + ) + # a missing agent-input field is fine (filled at start), the rest is checked + assert tool.validate_args({"sku": "A", "quantity": 1}) == { + "sku": "A", + "quantity": 1, + } + for bad in ({"quantity": 1}, {"sku": "A", "quantity": "many"}): + with pytest.raises(ValueError): + tool.validate_args(bad) diff --git a/packages/uipath-codex-sdk/tests/test_watcher.py b/packages/uipath-codex-sdk/tests/test_watcher.py new file mode 100644 index 00000000..68bf5e3a --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_watcher.py @@ -0,0 +1,280 @@ +"""The suspend watcher: when it kills codex, and how it fails. + +Driven with fakes (no codex): a host with held calls, a shim, an item tracker +and a codex object whose private process handle records the kill. +""" + +from __future__ import annotations + +import asyncio +import subprocess +import time +from dataclasses import dataclass, field +from typing import Any + +import pytest + +import uipath_codex_sdk.runtime.runtime as runtime_mod +from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError +from uipath_codex_sdk.runtime.runtime import ( + UiPathCodexRuntime, + _ItemTracker, + _SuspendState, +) +from uipath_codex_sdk.runtime.tool_host import SERVER_NAME + + +@dataclass +class FakeHost: + held: dict[str, Any] = field(default_factory=dict) + starting_count: int = 0 + inflight_count: int = 0 + mocking_count: int = 0 + last_activity: float = 0.0 + closing: bool = False + + def close(self) -> None: + self.closing = True + + +@dataclass +class FakeShim: + inflight: int = 0 + last_activity: float = 0.0 + + +class FakeProc: + pid = -1 + + def __init__(self, wait_error: Exception | None = None) -> None: + self.killed = False + self.wait_error = wait_error + + def kill(self) -> None: + self.killed = True + + def wait(self, timeout: float | None = None) -> int: + if self.wait_error is not None: + raise self.wait_error + return -9 + + +class FakeCodex: + def __init__(self, proc: FakeProc) -> None: + self._client = type("C", (), {})() + self._client._sync = type("S", (), {})() + self._client._sync._proc = proc + self.closed = False + + async def close(self) -> None: + self.closed = True + + +@pytest.fixture(autouse=True) +def fast_watch(monkeypatch): + monkeypatch.setattr(runtime_mod, "WATCH_INTERVAL_SEC", 0.01) + monkeypatch.setattr(runtime_mod, "QUIET_WINDOW_SEC", 0.05) + + +def _runtime(settle: float = 60.0) -> UiPathCodexRuntime: + agent = UiPathCodexAgent( + model="m", + prompt="p", + tools=[uipath_process_tool("a_plus_b")], + durable_settle_timeout=settle, + ) + return UiPathCodexRuntime( + agent=agent, runtime_id="rt", entrypoint="agent", file_path="/tmp/main.py" + ) + + +def _durable_item(call_id: str) -> dict[str, Any]: + return { + "id": call_id, + "type": "mcpToolCall", + "server": SERVER_NAME, + "tool": "a_plus_b", + } + + +def _immediate_item(call_id: str) -> dict[str, Any]: + return { + "id": call_id, + "type": "mcpToolCall", + "server": SERVER_NAME, + "tool": "web_search", + } + + +def _tracker() -> _ItemTracker: + return _ItemTracker(frozenset({"a_plus_b"})) + + +def _quiet(tracker: _ItemTracker) -> None: + tracker.last_activity = time.monotonic() - 10 + + +async def _watch(rt, codex, host, tracker, suspend, tmp_path): + return await rt._watch( + codex, FakeShim(), host, tracker, suspend, str(tmp_path), "thread" + ) + + +async def test_waits_for_a_durable_call_that_has_not_arrived(tmp_path): + """codex started two durable calls; only one reached the host. Killing now + would let the second start a job the suspension never records.""" + proc = FakeProc() + host = FakeHost(held={"c1": object()}) + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + tracker.on_started(_durable_item("c2")) + _quiet(tracker) + suspend = _SuspendState() + task = asyncio.create_task( + _watch(_runtime(), FakeCodex(proc), host, tracker, suspend, tmp_path) + ) + await asyncio.sleep(0.4) + assert not proc.killed and not task.done() and not host.closing + host.held = {"c1": object(), "c2": object()} # the second one arrives + await asyncio.wait_for(task, 5) + assert proc.killed and suspend.killed and host.closing + + +async def test_a_simulated_durable_call_is_not_counted_as_arriving(tmp_path): + """c2 reached the host and is being answered with a simulated result: it + is neither held nor starting, but it has arrived.""" + proc = FakeProc() + host = FakeHost(held={"c1": object()}, mocking_count=1) + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + tracker.on_started(_durable_item("c2")) + _quiet(tracker) + suspend = _SuspendState() + await asyncio.wait_for( + _watch(_runtime(), FakeCodex(proc), host, tracker, suspend, tmp_path), 5 + ) + assert proc.killed and suspend.killed and host.closing + + +async def test_a_call_that_never_arrives_is_bounded_by_the_settle_timeout(tmp_path): + proc = FakeProc() + host = FakeHost(held={"c1": object()}) + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + tracker.on_started(_durable_item("c2")) + _quiet(tracker) + suspend = _SuspendState() + await asyncio.wait_for( + _watch(_runtime(settle=0.3), FakeCodex(proc), host, tracker, suspend, tmp_path), + 5, + ) + # closed before the kill: a late tools/call is refused, not started + assert host.closing and proc.killed and suspend.killed + + +async def test_kill_wait_timeout_still_suspends(tmp_path): + proc = FakeProc(wait_error=subprocess.TimeoutExpired("codex", 10)) + host = FakeHost(held={"c1": object()}) + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + _quiet(tracker) + suspend = _SuspendState() + await asyncio.wait_for( + _watch(_runtime(), FakeCodex(proc), host, tracker, suspend, tmp_path), 5 + ) + assert proc.killed and suspend.killed and suspend.error is None + + +async def test_watcher_failure_fails_the_run_and_stops_codex(tmp_path, monkeypatch): + """An unexpected watcher error must end the stream (by stopping codex) and + surface as a typed error, not leave the turn blocked for a day.""" + + def broken(*args, **kwargs): + raise RuntimeError("disk on fire") + + monkeypatch.setattr(runtime_mod, "find_rollout", broken) + proc = FakeProc() + host = FakeHost(held={"c1": object()}) + tracker = _tracker() + suspend = _SuspendState() + await asyncio.wait_for( + _watch(_runtime(), FakeCodex(proc), host, tracker, suspend, tmp_path), 5 + ) + assert proc.killed and host.closing + assert not suspend.killed + assert isinstance(suspend.error, UiPathCodexRuntimeError) + assert "ROLLOUT_CHECKPOINT_FAILED" in str(suspend.error.error_info.code) + assert "disk on fire" in suspend.error.error_info.detail + + +async def test_watcher_failure_without_process_handle_closes_codex( + tmp_path, monkeypatch +): + def broken(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(runtime_mod, "find_rollout", broken) + codex = FakeCodex(FakeProc()) + codex._client._sync._proc = None + suspend = _SuspendState() + await asyncio.wait_for( + _watch( + _runtime(), + codex, + FakeHost(held={"c1": 1}), + _tracker(), + suspend, + tmp_path, + ), + 5, + ) + assert codex.closed and suspend.error is not None + + +async def test_an_immediate_call_on_the_host_blocks_the_kill(tmp_path): + """A held durable call plus an immediate call still running on the host + (it may not have an item yet): no kill until the immediate one is done.""" + proc = FakeProc() + host = FakeHost(held={"c1": object()}, inflight_count=1) + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + _quiet(tracker) + suspend = _SuspendState() + task = asyncio.create_task( + _watch(_runtime(), FakeCodex(proc), host, tracker, suspend, tmp_path) + ) + await asyncio.sleep(0.4) + assert not proc.killed and not task.done() and not host.closing + host.inflight_count = 0 + await asyncio.wait_for(task, 5) + assert proc.killed and suspend.killed + + +async def test_an_immediate_call_is_bounded_by_the_settle_timeout(tmp_path, caplog): + proc = FakeProc() + host = FakeHost(held={"c1": object()}, inflight_count=1) + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + _quiet(tracker) + suspend = _SuspendState() + await asyncio.wait_for( + _watch(_runtime(settle=0.3), FakeCodex(proc), host, tracker, suspend, tmp_path), + 5, + ) + assert proc.killed and suspend.killed and host.closing + assert "Suspending after" in caplog.text + + +def test_tracker_counts_hosted_immediate_items_as_non_durable(): + """An immediate uipath_tools call is not a durable one: no phantom + 'durable call has not arrived'.""" + tracker = _tracker() + tracker.on_started(_durable_item("c1")) + tracker.on_started(_immediate_item("c2")) + assert tracker.running_durable() == ["c1"] + assert tracker.running_non_durable() == ["c2"] + assert _ItemTracker._is_hosted(_immediate_item("c2")) + assert not tracker._is_durable(_immediate_item("c2")) + tracker.on_completed(_immediate_item("c2")) + assert tracker.running_non_durable() == [] diff --git a/packages/uipath-codex-sdk/uv.lock b/packages/uipath-codex-sdk/uv.lock new file mode 100644 index 00000000..9ab9d6ce --- /dev/null +++ b/packages/uipath-codex-sdk/uv.lock @@ -0,0 +1,2638 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/2f/6a91adaa2dc26877d6ed2f54c0370c8910f019db7d77c5c6a194611e93ea/aiohttp-3.14.4.tar.gz", hash = "sha256:831fc5bd39ec2517851e348f613ddb5447a47cf4b71cb09845af7ad7ed45d8f9", size = 8077839, upload-time = "2026-10-05T17:43:48.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e3/998dc75634ecd2a24f33bab480b57313059d55bcadff8825a46b7c80a8af/aiohttp-3.14.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb0093ca0817019d539f0d27bdf98e823944e05bb7efd9d2337a45b92e4d1b06", size = 832892, upload-time = "2026-10-05T17:38:16.373Z" }, + { url = "https://files.pythonhosted.org/packages/58/c9/9bacc26397854440d169f70ae00ea51721f504611d3a64cf043643792c32/aiohttp-3.14.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f482dc9c8309801b4259dd218609d3dae0a7bcdedeac56d90500eabaa0c23465", size = 559893, upload-time = "2026-10-05T17:38:18.74Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/90c5856014328a8b1e9a753de794edea282182b776ff65f0602ad4ffa520/aiohttp-3.14.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa800bb7c1d00e166493931f2ddacb982db913292503dc2f4884db1f2bc5f105", size = 552850, upload-time = "2026-10-05T17:38:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/e71c29ce6daa78fcee0feb1992f0fdc408f09f42c3311b1f1c68c36f16ea/aiohttp-3.14.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d90e4dae71b26597f6d1fd12af7d092ae67551edf0e524b73e0687214b79ee33", size = 1928941, upload-time = "2026-10-05T17:38:22.218Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4f/f0f3bebfa521a2ce5fd4302c937701cbc50cd6d908beaa597388639b0b98/aiohttp-3.14.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b681054b8c3b86aa6a0384b6ad7f5cb78dccbabab1f4a3a6793f98ad1378959", size = 1882092, upload-time = "2026-10-05T17:38:24.016Z" }, + { url = "https://files.pythonhosted.org/packages/b5/0d/5df041fd25dceb4f4cafbbee49bb2c1e2d54a91aedecaf03b77cbb404c44/aiohttp-3.14.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dfe80b41bcf8d80d2125fb99a171adeb86db2fcb35c1a68e0226acba0ba7f03", size = 1986051, upload-time = "2026-10-05T17:38:26.022Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/e95eddafffef7c3a74446f67ac4e477a49cec2c78f2ab6c86d1acfd90d2d/aiohttp-3.14.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:71e4a59c6a8c5a6ae8b0b696c32635913e8f526cc01448762eaf5a3c59df8a4f", size = 2107840, upload-time = "2026-10-05T17:38:28.112Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/1f83990fea249dcb10f48086726953546efc9199c96b6be7114532ac361f/aiohttp-3.14.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f887c3b7fb3058fc1ccd917f52078540c18d938d12018cb9edd64b6811d20c57", size = 1930721, upload-time = "2026-10-05T17:38:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/48184a186ea26df4f81dd466144dcab7f9cdd1e131bfee0f0e41278c6f0d/aiohttp-3.14.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0da4525e9ba145a11617d2cd7e44fe1acaf471f9c72a0cad28e72879c1020fbb", size = 1774508, upload-time = "2026-10-05T17:38:31.8Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ef/a9ec758c6d3850453838c31b56d1e4499eb47c80883753a12dcb203f9afa/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7e8d3c5caa44fdbee3eba1aa1417aa58c74c95d4089fe3c451911117fcbb2f3", size = 1902311, upload-time = "2026-10-05T17:38:33.791Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/9a2be54900f2dea6e63bbf9628fced604686a2e301130ccde0b1aa2e7e53/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:09092cf906c18c824b6b16881da5a89cc9cfa9b15344e496bcf880e9045bee33", size = 1912107, upload-time = "2026-10-05T17:38:35.614Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/151254abfc0fe5cbb8e6c41ad04d52122db0af176fb31dbf3bc44e081bda/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:dfd144837e92264878bba6d3a7ffa8206668b21e87748740afd45d193e48c87e", size = 1960207, upload-time = "2026-10-05T17:38:37.322Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/92c8dce336ac5784755a3144623528516f2e3b6b4871d02cad5a0642ae66/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9413cffe4e0d654b9b524f9c99dde8cbdb37e0ed9a2c4edd2e63adbac6b374e8", size = 1760168, upload-time = "2026-10-05T17:38:39.214Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/f05fd8d1a9595730db8d522882eea76429b9be2ed5337609f7c6e3d9d49c/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f50d719f97ba488dfb8e306cf8c8cc172ff0683ec0af53917b288229aa555ba5", size = 1979946, upload-time = "2026-10-05T17:38:40.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/8941aa73ffceabc2a0eb26878b4f1b2958fb7b5358f97cc49b1c00ed9258/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68ed4824d96b7afec1f3a8d7fba190fe282718041c5772e20298003e1e3e5d66", size = 1923375, upload-time = "2026-10-05T17:38:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/18/b5/ef275b63c8bfe0a21e7e8d3ef0e67c4745ed403f1894fe5205057cedd3b9/aiohttp-3.14.4-cp311-cp311-win32.whl", hash = "sha256:d7a41d1427136828e6b11d3f3fd194d4357d5205a5b610be7ae07b1ba52ab974", size = 488414, upload-time = "2026-10-05T17:38:44.978Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0b/21bdd70a1e072222d9dbd96bed4fbed26f7c46c5a20af11b78d32fd049d6/aiohttp-3.14.4-cp311-cp311-win_amd64.whl", hash = "sha256:2efbdb87e79d596325c4eefaef0f4495d701145e882351310ebc538e854dc7a3", size = 512755, upload-time = "2026-10-05T17:38:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4c/bb324a7d20a9598187bb028ca8d6bcf66c63589c8632420919dd576964ed/aiohttp-3.14.4-cp311-cp311-win_arm64.whl", hash = "sha256:ac6e6f90d9360e460c873f945f11dc8698db0a71fec6612823f1aaaab2c11faa", size = 492960, upload-time = "2026-10-05T17:38:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/22/02/db5935d45347fa8ede56b777930740fb0e72bbb4473ddb6ff8541f6f2afa/aiohttp-3.14.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:55b0b335982b0db7117dff7f6a8e3aad43c4105f9e392ca04f9fc8d958a95cce", size = 817689, upload-time = "2026-10-05T17:38:50.69Z" }, + { url = "https://files.pythonhosted.org/packages/e6/56/f00aa46127e6342f019d567853eeeb932e04fb9b9424b5fb981ad68f4178/aiohttp-3.14.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d0d8e435582b0d53009dac99e26a3d2be932ed7c9dbfcfc70f3fce1cf943bd5", size = 549599, upload-time = "2026-10-05T17:38:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bc/825c09b09962253a2103e4a88c04374ee939c0c8ea31c270daa0308efb07/aiohttp-3.14.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf734016932d68e1324cfb638cac7a0866a83933a33badf15e836a7e24efb8c0", size = 548132, upload-time = "2026-10-05T17:38:54.629Z" }, + { url = "https://files.pythonhosted.org/packages/40/80/8a1bc8036540fe7cb38219cc5c0aa67d32e9068165725e8199ea8200b08b/aiohttp-3.14.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:322e3d741b3133bcbb981a41d4f9b8ac3545c18de49c662c2b1f4e1c770b98ad", size = 1898485, upload-time = "2026-10-05T17:38:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fe/65b464d8520ace0d65b4ca69bc9abe175f842c8ce8d1522d363d9669903f/aiohttp-3.14.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15a3bb5f90a4e515071f3617a45dfe26560beb834a6b6e108cdf4bc9e9719ec3", size = 1875334, upload-time = "2026-10-05T17:38:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/9c583be502720e927560a8414f4dfb7c7de41945e1a30ccf7003b93c6f85/aiohttp-3.14.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c044e52df1a466af82cad3518c92ea80cc4ddcfea86966ff083bb34bab29bf4", size = 1937772, upload-time = "2026-10-05T17:39:00.376Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/b56512df7ea9cbb5f59099a6b375088dd578356a5a3fb27198ab82e19832/aiohttp-3.14.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d613d7d51bf06fe9a5e3aab86ce06d835bb0ceb1a31ea9e8766df4a403ae62a6", size = 2066220, upload-time = "2026-10-05T17:39:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/11/6c/a7a42701d9ea319140a3d44a24ec72a00b0b46d1b899b26d8a705e65bdd8/aiohttp-3.14.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5e93ab6e6330dce40f5b43996f1fc5f2543296d263a5e4b59ff09e73870b7d8", size = 1922895, upload-time = "2026-10-05T17:39:04.638Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/f8337bd824ca3fe3968d34dc5037ace1c4872551c4621f8266239311d301/aiohttp-3.14.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20134dc8e68c67e7478124d516449428684c0545b2d3faa575691c8bcbd56678", size = 1712589, upload-time = "2026-10-05T17:39:06.671Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a6/d20e2c940b592d7c4a21690889dbff64fe0ca3facd9190696368694e1488/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f0daef5ef012369dddc391c61f604111424aa9a96313a69fb899018b09d1b01e", size = 1862251, upload-time = "2026-10-05T17:39:08.523Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e4/d50edd5ae5d024b380a8e892e333840bb22967a9eb9c8492108a403f678a/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:19290a108de0718b73bc69b86e788975c6a59855c671ebe2a98a07a6f968328f", size = 1895155, upload-time = "2026-10-05T17:39:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/f36f2931f807ccf8437ec15e8f8bfaa4748d4792639401cc767a866abaa0/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1494877625baeffab669f4a25a09ec4eea721b49e35363081a2a8e4231fe141f", size = 1906623, upload-time = "2026-10-05T17:39:12.411Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0a/94821b711493142bf5e11c27b0d7502776778f7792c77ecc6647357d20c1/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a779ce4bf099ce4ad2cdfed62f1ca536d435f53237c54ff40172fddada24d1b5", size = 1698932, upload-time = "2026-10-05T17:39:14.446Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/3d5a0d28362f0053c1c457024749911932481e8fd105fddf8305b1f93c81/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0b4cec9fca876e2d4f4c6cb32b177390132d04618867689f43fefabf3f5c3980", size = 1934528, upload-time = "2026-10-05T17:39:16.354Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/9784a21dc05d2e0f6acaf864f16db53dab0fbd7b0753cef168601eacb012/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:217707313e23aedde9bea67cf8365c7b138b549ae179121ef3f476cf67e3173a", size = 1901984, upload-time = "2026-10-05T17:39:18.299Z" }, + { url = "https://files.pythonhosted.org/packages/44/4f/f98c88703e6d620243aa08473a946ca4e3756b6288690e38f0d9f9c1d007/aiohttp-3.14.4-cp312-cp312-win32.whl", hash = "sha256:18a9fb9a3f6e6e63f4d27f9adeef313a2d3c69a84d31170a844623824e722534", size = 480278, upload-time = "2026-10-05T17:39:20.206Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bc/8555013f84b53e4fdde423131b641af50e822c6d7d9118b24c52b31f6900/aiohttp-3.14.4-cp312-cp312-win_amd64.whl", hash = "sha256:69dcb02d33dfe415d5ee342cc63b7d320efcaf4b761701c7bd7bfb91c715481a", size = 506317, upload-time = "2026-10-05T17:39:22.706Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/ca5bad7bfdaa7ddca053a044a1c7c0d4f3ad9d1f12f96dcab10bfb9c50e7/aiohttp-3.14.4-cp312-cp312-win_arm64.whl", hash = "sha256:d36b0263e7c2fbf1750b9f35d4e7e48b0442d8c45e4a89e3e74168821fb014bb", size = 487683, upload-time = "2026-10-05T17:39:24.482Z" }, + { url = "https://files.pythonhosted.org/packages/23/ec/7fee140da700ec3f5aa5e0fd2319f67be5e86de197e2384d27e5e6209e0e/aiohttp-3.14.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:264b5a7568306590c44ae4a0237ad1715e35d447ea44ebf324e4c8fd9f78f67a", size = 539114, upload-time = "2026-10-05T17:39:26.322Z" }, + { url = "https://files.pythonhosted.org/packages/4e/04/930c01ce9e186e787046433d7dc5b45b188885f8406c14b58435b4431974/aiohttp-3.14.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:17e5d7c7775d8dd8e894c7ef6ebfa26509de36868a9a6c7c4fcfaf6a1bf43d60", size = 553514, upload-time = "2026-10-05T17:39:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d7/983e3cea81c07e95f35d34da4d1e182b5c05489c49b7560b198a83f0da79/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e91dc8fe9cbd052d16f7f1267e285cf8bbac4d2194bd5741f05ef201c77ed09e", size = 521381, upload-time = "2026-10-05T17:39:30.419Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/80baed11659093883f51245755105557d3253e45bd0b693d70df0059d68b/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8df6157d31703972aec9e3b93963f039ff06318301e30b007c3e941ff8a7ac42", size = 528347, upload-time = "2026-10-05T17:39:32.344Z" }, + { url = "https://files.pythonhosted.org/packages/c8/18/bfda255c311096c08dab26dc65f3d219f090df37dc5f5f6345f8c3aefc1f/aiohttp-3.14.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:220912b351549dc736c59d104dc443fa615753102bb778a4e98f86e4d94bff6a", size = 539914, upload-time = "2026-10-05T17:39:34.265Z" }, + { url = "https://files.pythonhosted.org/packages/51/fc/3b6ca6117c0c42481d719411fff7b5d3970ebd906543d3dc1ba40fe2462f/aiohttp-3.14.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2efa42b39bb3f524d5eca3760639b584afdd1143a6b047fda29910ffb75e4993", size = 813997, upload-time = "2026-10-05T17:39:36.061Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/86e3e02a9712aadc1ae0a1076bb2d1adf3d9addfe5542a28190c8f9b6d9b/aiohttp-3.14.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e7356059c8262d2c1fd95242e2b7b70fbc6eee3135bf470049cbec3e968b5b0", size = 548058, upload-time = "2026-10-05T17:39:38.109Z" }, + { url = "https://files.pythonhosted.org/packages/85/7e/6b9c2e271419d40ca76589613b3c7ab185fd3050a407bd2565b6aa46f695/aiohttp-3.14.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fac247d0cc956d732df1211ac2d8a9999ec8dc1a92a90401de9d52e4ead546f7", size = 545933, upload-time = "2026-10-05T17:39:40.193Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/33d6e24a5f8ac32423230088c415e2a1c6efee23d902b62f20506631e40b/aiohttp-3.14.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2eb0f8b03b4f2bd154ed8cf7c1a119a1ad04808c258bf58f90138c83b8c6d5", size = 1888265, upload-time = "2026-10-05T17:39:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e8/2eca1d468d83a514621d1280f9992d08649191db2028c9236b4edfa7c723/aiohttp-3.14.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbe827d27e0e369fd7bb88ceeaa5b46ea006e73e015b9dd77e3723a53162293", size = 1872300, upload-time = "2026-10-05T17:39:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/5872983de71b0214acdfb86822f5cca295b39be4832ec589fa224aee0f6c/aiohttp-3.14.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70387146d03af9f047c629b71aa27d9594b3b31ec4c9c91e1a73d0d8bf38e0e", size = 1926213, upload-time = "2026-10-05T17:39:46.199Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/ebfefa11a546c33c717af63c67868408ecf0b33b23a9d75669af95485407/aiohttp-3.14.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d0934926fc65744e2fdd44ce68b2d79d5ee608a1e23f0596b35dd696519bb7c", size = 2056609, upload-time = "2026-10-05T17:39:48.221Z" }, + { url = "https://files.pythonhosted.org/packages/69/c0/70667e82ec041f4122a17afce711bbcf94a71bec5888b69a49aed8817be2/aiohttp-3.14.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4b366510c733974adb7a4102c094e4a6308e6f369315a4cf14d2d0a619cf82", size = 1914740, upload-time = "2026-10-05T17:39:50.304Z" }, + { url = "https://files.pythonhosted.org/packages/bf/86/559e9dfea676206b20737d7ccb5b3716bff5e58d2a8d53682e5301724cd1/aiohttp-3.14.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85fac7c99e0ac3dcbd6ae1c33774e0603bfc732ddb967a6fce4a731103c1888e", size = 1703390, upload-time = "2026-10-05T17:39:52.385Z" }, + { url = "https://files.pythonhosted.org/packages/fc/91/c98897d4cf23e120b2f3980042835cb34c86bbe3c653f3eaacf4bf986349/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef1e9c3b4a2300023a5dc865889ce532cfaf6798ba4ef78fbbefe738a9d23efa", size = 1852642, upload-time = "2026-10-05T17:39:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/14/13/0258f352cb2c236f358a5b57d138e15fa2566e17accc0758d968dbf2ceb6/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:97e65916588be0f952b3307aaa8460c0771fa98dbea2dd27dabc9f5d84d53c3b", size = 1890616, upload-time = "2026-10-05T17:39:56.466Z" }, + { url = "https://files.pythonhosted.org/packages/64/3c/f43e294ce1a06d1234a97bd862c1aa31acfa1a0eea3786c22fd0dc1ea731/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:840775cb39a9424f9edf142133a1192263ac5bd79117ad2a16602cef6a0c1e9d", size = 1896638, upload-time = "2026-10-05T17:39:58.585Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f7/ce1e3a85b73d05037d729d3d32da7d6959b9e433435e9abd46c2fb38410f/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:54f9256430ca040d58affbd551513de13a5cff7d065204fc52b9a1b0af09a049", size = 1693003, upload-time = "2026-10-05T17:40:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/45eb900d6a9ee046728f5589f3d07f5d78f049b5fb84f48881fa4174d5df/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6112d5931bf12c776188cab81b82bbb17d86e2e1381f2ade6edc43e6b240f0cb", size = 1927730, upload-time = "2026-10-05T17:40:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/b2/43/a83ff79dc8375953a1f00b808a21c82590f4e68304fecccdcc42a25fb7d8/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:facc6df39047c4732cbd885bb62e40df20885fcd709fc399e11f81b45ce0fff9", size = 1891177, upload-time = "2026-10-05T17:40:04.989Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/fbdc41608d4e2fb6084d960a41b392fa82beb5b126c2466de549b5c67fc8/aiohttp-3.14.4-cp313-cp313-win32.whl", hash = "sha256:7968634fa3a967a2bc0b9aeba1d5a1955501dcd7d0de44b96f7ebfb662797735", size = 479943, upload-time = "2026-10-05T17:40:07.402Z" }, + { url = "https://files.pythonhosted.org/packages/35/51/d75629750e705832fd5cd52516a82943ed58b6e09c30e14830f5d87452fa/aiohttp-3.14.4-cp313-cp313-win_amd64.whl", hash = "sha256:c0a894b0265d139cd4c2c8bd4643822cc289a4a5331e21e810881bd000ffb2dc", size = 506209, upload-time = "2026-10-05T17:40:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/02/64/66452d2ffcc13e5e5ee4ba4a968656fa4a98c29ed5ff0205d4fde02b2943/aiohttp-3.14.4-cp313-cp313-win_arm64.whl", hash = "sha256:da16c3037178e72589d91de59536d271dffc92c2be2b47c38a45538cba54fd29", size = 487163, upload-time = "2026-10-05T17:40:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/b653b1f969e2e949fe4726af7a8043e8ef117f202bed7757ac8564e077a1/aiohttp-3.14.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:749bfc43bb1df71bbe6ed215044d5412d786698fc5771d2037436ca49622f3c9", size = 540738, upload-time = "2026-10-05T17:40:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/90/b5/eea49a0bb697deffac5165d707ec53a24748d6720b3af77a544107b49573/aiohttp-3.14.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c113e4b489d711f606ca278cd8dc89179746244ea841aa0638b1b134600afad0", size = 554494, upload-time = "2026-10-05T17:40:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/11/ea/cf8f4efbbfa8572d9965cd43b3c6c84c581aa006772e5ce3ab4020027e9b/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:50969522bc7022026a2c7968e67da5c789f6b716b988edac55dc630390efd5df", size = 522653, upload-time = "2026-10-05T17:40:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/a3/24/9f6721f0b52323789cb1041ffc3afa729462d9925607e733a065b6fc5e17/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:cbf92cd7a1c800cee244558dd5630abb45c1c1093ef7b0d546a99f526ff7a9eb", size = 529697, upload-time = "2026-10-05T17:40:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a4/399debc3c2ca0ab88a08e3082b4c4f62d0f66978af60243c63f2345ef337/aiohttp-3.14.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fc76be08ff407fba717a769aff04ca1b4bf9ecfdcb5caadef84a563fdbbd146d", size = 540526, upload-time = "2026-10-05T17:40:23.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/90379813b18b795bccdeb3a5a85e9b93ac2c69f9c2a5d0faba59da3ad6d1/aiohttp-3.14.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b416f995b68a921069e6ec61fa07f2ac3ff033d92e97a69f673af89f5bb954d", size = 818866, upload-time = "2026-10-05T17:40:25.842Z" }, + { url = "https://files.pythonhosted.org/packages/78/70/824fd0dac819652f1df895b3f621a75bf0a3050c30e38d8d2316e34ab821/aiohttp-3.14.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2c9585101cfca10e74a07cc0ef2bdc6d90616e6750926b20456f429d0608f740", size = 549666, upload-time = "2026-10-05T17:40:28.523Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/eafb2dd200dd2b770ed25a2dc5731c4ba5d58ab3fa8e2e82494bb946808e/aiohttp-3.14.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3c4dcf611b095e7983421f49d01ffbed7ed574891533d606ea115fc23e7c4e90", size = 548961, upload-time = "2026-10-05T17:40:30.613Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/6c733a1c71300293471ac7ef1dffd12b5b4dc9a43c53461224b2b8a7f667/aiohttp-3.14.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8f61a9b5fa56c58e3441f9dc64137f786232d40fb89127190d0e0f84e1e5865", size = 1883165, upload-time = "2026-10-05T17:40:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/34/885ddbd351679a31141b25e0d1a5e4f67738806e564c858b399709b61239/aiohttp-3.14.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d43e538dfc85b705fd832620d050970ef5613b3869a7169b44a554b9dab9eee9", size = 1844119, upload-time = "2026-10-05T17:40:35.266Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ef/0a64dac53c11d569851efb3e7eddb43551df72b2c172ebb34cccc93bcd3f/aiohttp-3.14.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4135c4bb4b7682a7633fadc7170ea969d843f83aa7224e70111a20675105c985", size = 1930222, upload-time = "2026-10-05T17:40:37.527Z" }, + { url = "https://files.pythonhosted.org/packages/69/5f/774792ed8f973e0d234fcb06b0f0bc9f29e9a0a0dd847f6418c2904be7d9/aiohttp-3.14.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9055324f156a94fdf6dc9a825ae60f9a2947b88f591ea81ee80826633de62ae7", size = 2038842, upload-time = "2026-10-05T17:40:39.815Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/e00f35581c97656d9e864edaa03292382c620dd1d9c836a4f73a2279acc4/aiohttp-3.14.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb22c8e4b60385a9b414c70c6e8b2d0f4b23ff7fc4bb572ceb58297b228169f1", size = 1887845, upload-time = "2026-10-05T17:40:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2e/07e3eff5b4c1b082a2eaf0ffb508b409b21ea1ddc9fdc57f3eabb51469a1/aiohttp-3.14.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b6945559a260112742ed58cde2165488d2fc51aeea34afbaa99d9371b43e1b5", size = 1702533, upload-time = "2026-10-05T17:40:44.569Z" }, + { url = "https://files.pythonhosted.org/packages/75/44/816844b7f876e6f8fee4ecb088269fcd92241da712e4814a4d624eda13a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f4078a90b174f15efb2c8725e8720c40dbd3b2cf6350dbc085c964a938d9de7", size = 1849058, upload-time = "2026-10-05T17:40:47.818Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bd/163242e12464a1f3e58a77e28beca949652ddce84abdd5500a91c70a1e0d/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f433c5654e32f72de3ef074d715eb5c7604eb6d28a5c2f3c9e5056f20fe6b306", size = 1854600, upload-time = "2026-10-05T17:40:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/59/5c1ae708a89524eddfd9c727b87cae26b5c50c3c76683b910ac06151ead0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:871035fe79c636b6bc126ed78df37d72b830dfc751612cb4b1040360edf2719f", size = 1896565, upload-time = "2026-10-05T17:40:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/95300a7de69241fe47d3f838f3fb582c712782e172d447ff650a013ea673/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2617132524f66338f6c1d218cd16081576aefc40df4f2bcba338a99fbf67fec1", size = 1693092, upload-time = "2026-10-05T17:40:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/ff/33/01a8e61d48d0094121092c74125df14c0f8f10ee3149bfe68b6f869409a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d8bc326133272deee8ef337581550450e4bb735c0520be893b950a1f5baaf41a", size = 1917073, upload-time = "2026-10-05T17:40:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d6/1a4f24624cda6686d5b927e26d5d1ae3ebb692faf88e93017fab4b391fd0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ec57223e261b7fa9979c12dbce009ca53439be76cac3af27f860d3e4b1d6f7a", size = 1872656, upload-time = "2026-10-05T17:41:00.123Z" }, + { url = "https://files.pythonhosted.org/packages/be/be/ed286fcfa2d22061ae021b568c790123505cd482df35e23023339ecaed96/aiohttp-3.14.4-cp314-cp314-win32.whl", hash = "sha256:7242043e71fc449a19a47d92f4e9fd1e0ae8d3f88488982381f422bf6d651218", size = 485461, upload-time = "2026-10-05T17:41:02.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/74/e040e6f65f4da305777f1adb3023d47401693489e4a337c983b52d2a0a6b/aiohttp-3.14.4-cp314-cp314-win_amd64.whl", hash = "sha256:019587c7b3a44917dd61bcb312fd5c034f341dc9851b83e7b41b65e08b68ca0f", size = 512147, upload-time = "2026-10-05T17:41:04.98Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5e/bddb33ae15fdd5a68a10a986587aa9916c418a712fdd110d3b3ae9b51407/aiohttp-3.14.4-cp314-cp314-win_arm64.whl", hash = "sha256:60dc71d38db0988f37e78f6ad4a010a2f6355cfac3fff5f130cf5ef2231f3f0d", size = 494685, upload-time = "2026-10-05T17:41:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/22/2e/7b884014b40573f919fc3a528e5028bc37b249e31a93928ba61bdd25c960/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:674f53ab9d2794e2dd767619a3384bf1006f8d075e43c19d449a645f8ff641c2", size = 855696, upload-time = "2026-10-05T17:41:09.514Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e6/b6e2b4f580ab6ae39241bb9182aca60e6e1915cdad0148faaa04db3f366b/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6d243221f73e5ac546c2ed9255100423e4bc092b08b944f23498e8cf3096c26c", size = 567931, upload-time = "2026-10-05T17:41:11.685Z" }, + { url = "https://files.pythonhosted.org/packages/63/99/ae8e3a1a18adcbf636839d18b3cccc51007836ccdae5209381c5cf82d1ae/aiohttp-3.14.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0daa016eb74e7888ed5f9ceec6eea3158999c1c4a115ef5cbe88baa8d924f94c", size = 567421, upload-time = "2026-10-05T17:41:13.836Z" }, + { url = "https://files.pythonhosted.org/packages/63/38/c5d4bb58be72b967a4090d2deeb38571f8c992852936c51dbbbeae49db1c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70c031a622aa20add05ebd234b90adaf6bf51c1c88cc305ec760721af83308c3", size = 2054173, upload-time = "2026-10-05T17:41:16.098Z" }, + { url = "https://files.pythonhosted.org/packages/eb/87/3c8a9c7670f56d478a75c5bb84b336fb61a67b908e36c30a4a96ed8c8e86/aiohttp-3.14.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7df363fa7c38945a896e12c69af4a1a56cbd4152791d9e41fd0bac396b72de38", size = 1918296, upload-time = "2026-10-05T17:41:18.478Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/bd00e499c0a1505b44f5e23b9739034ea9be792ad42537f2894211ba1c07/aiohttp-3.14.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02a508ba5ad91c4d730523584c0adc65882274bba5e37c33bc2f475e37577256", size = 2027513, upload-time = "2026-10-05T17:41:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/23/1d/a3666a7c224f86a881f18d673a8ffdc92fa4c35274fb4361822f8f0dc566/aiohttp-3.14.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0cb3edece6fe6a944eaf97d803b88f606da6bc1600b1894c2969e78ffdc1fc29", size = 2152686, upload-time = "2026-10-05T17:41:23.181Z" }, + { url = "https://files.pythonhosted.org/packages/46/7e/f4204b8c4959c46f6367c182afbbacef08a1b53874281912c52fbd80151c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21ce1223b461bcc5fb389a7204001578f1c0b85c2da60a84d915ebb021294245", size = 2006431, upload-time = "2026-10-05T17:41:25.776Z" }, + { url = "https://files.pythonhosted.org/packages/33/43/091e69d42967cbea833ceb355c5457f8f70748fe08b88c9d91491b3fafb8/aiohttp-3.14.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35d5432f0dc1c8b71307e9112461b52f336da225e344283eec4779222c0a1dff", size = 1789101, upload-time = "2026-10-05T17:41:28.095Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/b57cbe533c01098c5d30471f057e7c3c19f281122f756acc9c716b636e2c/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6ec364493057c118d08292f7cd60865965c209167d8657b45643a26aef77495", size = 1974472, upload-time = "2026-10-05T17:41:30.499Z" }, + { url = "https://files.pythonhosted.org/packages/9f/44/79acce03611450e4ed8594f96f1cea40d70bc84fb4e918f94d7d9454a4af/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:70492983b7aed9e61bda82fe1ce80bcba9199610886443133783a288638923c0", size = 1956340, upload-time = "2026-10-05T17:41:33.009Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/36e6cbd6720e3377a02f8709aec6fe80de729ef41e78ba01f13d50363c73/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a77776673331577147e9d25cd7833a72e8891b47556336cff06ba1569e488b99", size = 1965771, upload-time = "2026-10-05T17:41:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/69/0f/879735ac70ce5e6852916eed51c706a517b205150a8d384f9fa60933a3fc/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:73799000dd6fd5247094fd8e6d3e1eb871e41aab5cc35dff9d9f654cd230bc24", size = 1780297, upload-time = "2026-10-05T17:41:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/7c030ca5b6baf3b02eccd11afc3d58f0b35e407031d5cc78cae9c24921c0/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:27b370fb532e707e8c96d04fa0c96301fb7dba2d50aaca57f6c456cdfffec5bd", size = 2001374, upload-time = "2026-10-05T17:41:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/ddacbb8141d727034c51dd192d17749521d5b30b03f84aa034325396089e/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:884e0d1c707b6217fee3080b9b4029eee7551af788ee761010df19be158414b7", size = 1968531, upload-time = "2026-10-05T17:41:43.62Z" }, + { url = "https://files.pythonhosted.org/packages/3a/08/2214f823701eebc3e57db141faeae76df7bfd415f254cc7c1692258f228d/aiohttp-3.14.4-cp314-cp314t-win32.whl", hash = "sha256:f2e3d34edb151d3e27f93a8338e2da33e2c7261cd4c879ce5f5fe280cc190ea5", size = 504126, upload-time = "2026-10-05T17:41:46.321Z" }, + { url = "https://files.pythonhosted.org/packages/91/a1/90e9b6fd23310b4e82d0c0a37b5ecc2944053350473c57533c1da9647235/aiohttp-3.14.4-cp314-cp314t-win_amd64.whl", hash = "sha256:88cf889e51092537fe44adfbff96c2a22d6479d6f92b97b3d413fbfd0a4decf4", size = 531494, upload-time = "2026-10-05T17:41:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/5b501472c92b9b1dc53742591d6210dccb262962f4721299300d3a9f664b/aiohttp-3.14.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ca450c6d42fda7c0bfaf0e200f32f77c5f2c960a33be1e14d197c84588b7f5f6", size = 510544, upload-time = "2026-10-05T17:41:51.358Z" }, + { url = "https://files.pythonhosted.org/packages/0f/77/ef165a197850573ea07c8d77bf5f4854a1db12fdb20e1cf0b218abf025fc/aiohttp-3.14.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:3b10799df72f66c30935bc661cb89e5ac2918debfc575e350e226fbaa846cbb7", size = 540737, upload-time = "2026-10-05T17:41:53.761Z" }, + { url = "https://files.pythonhosted.org/packages/e6/aa/1663f64d1316b3be9d3f6fafa02b8d888070171a6544cba515e82f3af595/aiohttp-3.14.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:7ffd7f9dd39caccc6b2f68ec0339435ecad2b20f6ea2e2b3de91bbe464ac7137", size = 554600, upload-time = "2026-10-05T17:41:56.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/f7/d4d535855123c1a774a798643e75fdfbac62c047b0c0729d6505d09df444/aiohttp-3.14.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a0a7a227353f7693f73878ad14cd2623cafadd656ababcd20e2adfb7225172ea", size = 523184, upload-time = "2026-10-05T17:41:58.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/18a8d1cd2958a0b6164b047c18ccb192f2123bb70f1c0a145f6f01f858ad/aiohttp-3.14.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1c43b6af8e4708e8b3812a9ee4f79895207e12d555b7f6d70deb838e0011e12d", size = 530342, upload-time = "2026-10-05T17:42:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/d1dd2b1fcf8a3192a431375b287a8762ebfd22195ab5b201d3fac30e3a7a/aiohttp-3.14.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:97416b2b997e5e9b817e322b2f0a89b76c54cde22471f51ac17ac15e10901ba0", size = 540629, upload-time = "2026-10-05T17:42:03.324Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/d1868f3459f618f6b68a6ef549d8b3f76aa616fa083298911fd3ae65d6b9/aiohttp-3.14.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:ee2a5aa31e507c17841d592364f2148393b17f83bc2766e9211e1eb31ea72a70", size = 819347, upload-time = "2026-10-05T17:42:05.713Z" }, + { url = "https://files.pythonhosted.org/packages/84/e6/53179b721b67e0bd20974d02eca97b5691559e8ce666ba23a65aecda6b7f/aiohttp-3.14.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:5d5d9843c34866e986dd24eef713452609e304550c471320ce7b54b3cfb1391e", size = 549825, upload-time = "2026-10-05T17:42:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/00/0f/d67ddf7a43d4899176954511b6c048328abec1a5df4901f811a3b408a4fc/aiohttp-3.14.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:efcbf93e31b52c21665b6ebc489eb6fdb0dfd86b592edc677b5cffdfa7b11677", size = 549315, upload-time = "2026-10-05T17:42:10.766Z" }, + { url = "https://files.pythonhosted.org/packages/9d/db/42f372c7f8e259505f239cb49db75f23548deaec56d22c68b4866f1c3fe2/aiohttp-3.14.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f1a79977794592dcf6485ed964ec2448db527a1bc0c72ec89663658dad3a739", size = 1892181, upload-time = "2026-10-05T17:42:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/23/0d/8b1900216302a94f69b41bf0786bb992a6e67d703f4698e335c2442db615/aiohttp-3.14.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:515e7ac6d27509dc3ba2c35fb88970b00b9e7b07ee7cc947c8733dcd54ef5def", size = 1883513, upload-time = "2026-10-05T17:42:15.704Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/15c014313e567fff931ec4a5f81683cc40251257791fbd607ec37c0dad80/aiohttp-3.14.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:185154ffa3b54765b5de96f464f41cfa476f8815a03854ae5e0fe4d48fb06e9c", size = 1933283, upload-time = "2026-10-05T17:42:18.241Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b3/bf7dc39a450218efb1d5c692bac7dc85ef56da282e889bee594f3267211e/aiohttp-3.14.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6aece59f77959628a22cd2ffe48b102cda9611d680e090f204385b3998a1190f", size = 2041054, upload-time = "2026-10-05T17:42:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b6/03/ffba18d7aaedb5eafac3f41f0c26621ebb0a740bb0115ea2fae1911d902b/aiohttp-3.14.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7205aa6589f1ad1306c6994ff2e1ab5efabacf938f9ffd7682fb225c197b06a", size = 1895494, upload-time = "2026-10-05T17:42:23.518Z" }, + { url = "https://files.pythonhosted.org/packages/45/f4/68123365b86826eee82bdceb7fb4c429204cb3a104d6b7ae44bc6a543fcb/aiohttp-3.14.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f38c7b3ce60d55ac0f2a9fd2edc194789d043fefea15cfb7dcb40b7d5f561146", size = 1714098, upload-time = "2026-10-05T17:42:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/b13c0b214afc571782e8667f3b9e0bbed6843397be90fa35acf2f3ecfbb0/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b0a1274ee4ee6203c15a341cce87f9f3df8151d5496047b3608bb62bd969cffc", size = 1857111, upload-time = "2026-10-05T17:42:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7f/4f5d3cee3c23b57119aebba5174962fed0bc493f37f38edab2f5730411b6/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:69ce2407951516f413adf3132b88451eababd710ce631f83c81a9603a3640253", size = 1892123, upload-time = "2026-10-05T17:42:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/5a6a1f014c4e17d58d4e935d0dd64e612f4eaa2f33cca2475b48ca5e3fb5/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:b84d3d057c60eb8e0c2b457a1203e868d82709d3f0c272a4975dfd88c2414939", size = 1898809, upload-time = "2026-10-05T17:42:34.34Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/d1cca5311a70fcc1c7c2082e02044578a69bcb10686db33a322df9fb849e/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4ecc84f69c9e217984f6877e832e4786a74567432c0bf2b7512919fbdfe18ade", size = 1704814, upload-time = "2026-10-05T17:42:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/0a/cb/cd58eae366148a5b9b1baa233542151154f2b72cd32daa39fd9db97d0c0d/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:878f2d7950238b2b0a8cbb88054444a125f0cfbc77f54c811aca0483132a9be3", size = 1918390, upload-time = "2026-10-05T17:42:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/f5e6c4939432734033048ba631503edf4a1e693ee5e261773e169803bbe4/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6ff9d3a0e6935f1cb0a6685f9f4b3f0d3e406456d7a434a03cf309b8cd5d7a2e", size = 1878321, upload-time = "2026-10-05T17:42:42.528Z" }, + { url = "https://files.pythonhosted.org/packages/2b/71/462957d6b5d4eb208f78e67745c41480bad2e738aa7caf1d9b07536920c1/aiohttp-3.14.4-cp315-cp315-win32.whl", hash = "sha256:e07dfd7cd360f20be26dc2487ad4c7f21b0b39fdd593e9ebf37d2424ad52defb", size = 485536, upload-time = "2026-10-05T17:42:45.223Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6e/48f59266a8b8c6420894ff59b8e74175252da67df85967b96997e96b7092/aiohttp-3.14.4-cp315-cp315-win_amd64.whl", hash = "sha256:921fc4f1ad549091bfc39cb5e93925b04f307f83517756a3111843851130efce", size = 512087, upload-time = "2026-10-05T17:42:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/25/f5/46263b23cee4f312e28aaabbdec2e074c807700a12c9dfa18beff461f035/aiohttp-3.14.4-cp315-cp315-win_arm64.whl", hash = "sha256:5be8af106f96fd625f6c264fa9faaaa9e0104089684d9015d2fa5b6eba6407c7", size = 494709, upload-time = "2026-10-05T17:42:50.615Z" }, + { url = "https://files.pythonhosted.org/packages/77/8d/e3b1d406c95a8fe436ae2e5f01ee9591196c6c22c44c645b24f20d73eaea/aiohttp-3.14.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e69727ec04a4608200ff32ec29478299df7657e2eb78c416ae338d0ca6662596", size = 856014, upload-time = "2026-10-05T17:42:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fc/e9e78d1097830040bc0a4b8af073379f964064bfa7f0a852d774f96f3168/aiohttp-3.14.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f8b658dd3ef2ebd7708b311ed96ff7cb5edaca001a30018a091504ef492bcb1c", size = 567724, upload-time = "2026-10-05T17:42:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/c813a705a1da1c111c30723b52e1efb67763e9f0b3b54a418cb419616e7a/aiohttp-3.14.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3749e018123df1205161a73ba96d336e17e9cf6b947295e53a43571de420e7b1", size = 567888, upload-time = "2026-10-05T17:42:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1f/7676f46aa24554a45686853c61552d02d67ade2b4c51adfe8b2ec4fdecd2/aiohttp-3.14.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f28a3c4fca436b2e594090e7cd22558f5e93a30ca18516c436de982da6fc25a", size = 2054356, upload-time = "2026-10-05T17:43:01.781Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/b9ef7d7442061e0a3125990d6021230b30361a972f7b8ebfb011042877ce/aiohttp-3.14.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e099aae21246992a1d84303ff262262d054c474e46cace33b0d5a69810c71877", size = 1937678, upload-time = "2026-10-05T17:43:04.492Z" }, + { url = "https://files.pythonhosted.org/packages/5c/98/5927a31540c8e6d0542effead5bd5c56e749a9d99d84e902fb80ae906ef8/aiohttp-3.14.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fafb521891e646d9cf5da89f01c75e7159d08be7254a024149630a698be28251", size = 2026705, upload-time = "2026-10-05T17:43:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/62/bd/1be001ed97dcfa57a292188c38979fb78be795b960b50f02660587bffa06/aiohttp-3.14.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d385db2cd154bce0b3dd451cf2a52bec91ebb7be2c3a5d06b14b4391631d807", size = 2147075, upload-time = "2026-10-05T17:43:10.141Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f7/d59628ccedd7c86db4c1f904467d1c80fed6cc10a1831ed5d75ff12e5a71/aiohttp-3.14.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae8a244889d8a53549851bf595b9f382f64acca57e8762da1ecb26ba786828f7", size = 2005016, upload-time = "2026-10-05T17:43:12.848Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dc/472db3ab1daafa12a61d7f83314f85f502b99c143c78d66554a5e24361fd/aiohttp-3.14.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:681dde68ff8d8d5e7d5abcad4feb45bfb457a218310c919825e21fc36cac3d0b", size = 1796363, upload-time = "2026-10-05T17:43:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/61/6f9834b131b9a42e30d68c469c196033d3cae2d8fe340d4668f362c4fd1f/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b91a6441fcaed88ca7bca67726b189332df3ceb6ac0dfc98dcb9654bb723288c", size = 1974240, upload-time = "2026-10-05T17:43:18.884Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/b8eb2a276685ef1da7f7bc15348404b8b9ee12b8f32f2cd8895385cd1589/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f15dbbac49bc15eaff95c0b8166525c4050996af578c7be141e145f40ee2cf24", size = 1973387, upload-time = "2026-10-05T17:43:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/5b/32/ad559b2d6817e85f7cb294d8641e975e64b526f4da98ffe941c9af67a047/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7abae043e87692d1c2adda1fab501bb0612d28c4a23eb082005c1e684f792dac", size = 1968766, upload-time = "2026-10-05T17:43:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/60/67/4a16e398140bdabb363428b9f439af3f77706a6df8bea6e626bbde53c2ab/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:21f4624fb1051fc1aa10d57a636a80e829f7d0417462d6f1ce380b08fe1a904c", size = 1788193, upload-time = "2026-10-05T17:43:27.467Z" }, + { url = "https://files.pythonhosted.org/packages/a9/38/17b326da7f52e530a675d789ade3e727a1b3db9959722f6049b9c392321a/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:2e8dc3a0fdaf9d12b954d3d06cc4c355d91b17fb6c671715b593624ca4c379f8", size = 1999645, upload-time = "2026-10-05T17:43:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/91b87961250010e05eb3adabaf0d98e4229ee7543147c265e834922e1b0b/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1410b6f6e3d52ea7c02c950496522ce6e85d5543bf154bc80a6141e505251cfa", size = 1970998, upload-time = "2026-10-05T17:43:33.364Z" }, + { url = "https://files.pythonhosted.org/packages/72/66/9bcf2c2db7b75a3b51c11470ceb67941d028727d674276ff5d5120adc87e/aiohttp-3.14.4-cp315-cp315t-win32.whl", hash = "sha256:a65189a89f3e621f7c27a968b5131bbca29126e2b8517dc72b205747a39d4c96", size = 504036, upload-time = "2026-10-05T17:43:36.212Z" }, + { url = "https://files.pythonhosted.org/packages/e2/20/349db4af12dbc16070d32028d30c2067123b4520f3dcb2d9013524efeb4f/aiohttp-3.14.4-cp315-cp315t-win_amd64.whl", hash = "sha256:2b2c95f5f769eec92b552969db1f79ea0282058ce5131ed0f2fa540b4af3f14e", size = 531284, upload-time = "2026-10-05T17:43:39.362Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/544c711bc5fad611278ebd153bdcab1bffce8b8581f8ec9096b275948a3c/aiohttp-3.14.4-cp315-cp315t-win_arm64.whl", hash = "sha256:54209fff79346ee1ef0d5cbf92fa80a5bb37396406bb4d932e5107f9c29b2d3f", size = 510623, upload-time = "2026-10-05T17:43:42.15Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/5e1f8446545be98e9416af59c396b1ed1f9f3294afb0a40ee3a709889273/aiohttp-3.14.4-py3-none-any.whl", hash = "sha256:5c6758ba62aea282c537179cfc8474a90f2b5f7b089cc5ff66d8920d86a9bfdd", size = 279487, upload-time = "2026-10-05T17:43:44.905Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "applicationinsights" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/f2/46a75ac6096d60da0e71a068015b610206e697de01fa2fb5bba8564b0798/applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3", size = 44722, upload-time = "2021-04-22T23:22:45.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/0d/cb6b23164eb55eebaa5f9f302dfe557cfa751bd7b2779863f1abd0343b6b/applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea", size = 55068, upload-time = "2021-04-22T23:22:44.451Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bidict" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/f2/8d2dd8276ca05e1f5157b6a0d34efb2f585f47a0fbed61e8aad04b221f0b/bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88", size = 30818, upload-time = "2026-08-25T23:45:52.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/53/2a3c7d562271ec6b6e38e7216b3104899f8aa4180c0713cb8aaf69e29cd5/bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364", size = 36175, upload-time = "2026-08-25T23:45:51.096Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "chardet" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/cd61c567092a6cec796144510a68aff158ebfc1df82950a45bae65f28413/chardet-7.6.0.tar.gz", hash = "sha256:93d9df6089ded42ed1fe9f57e272c0b74bd0464d45c0c7d50f09f26f31105c3c", size = 914462, upload-time = "2026-08-14T20:36:59.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/2e/d8634bee23a07bf512512ddf6218a68e47f045ae061c0cc80657ed79dcc0/chardet-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6424512f576fa7e88b7431d38a42d57552c8f717465a975fc42e497cd280d833", size = 1088243, upload-time = "2026-08-14T20:36:17.135Z" }, + { url = "https://files.pythonhosted.org/packages/55/95/bd6d59026638cec47dace85858171fbecadd2f9e58cb2b973dc515aa790c/chardet-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:284136186ff90735f901ed0a1c6d41e7af67c666841cc0eceb58482a21b7056c", size = 1068688, upload-time = "2026-08-14T20:36:19.183Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4a/60ed03656b28c1f4d378bc3cfe8a6cdda62c7c289398f925610fbbabfd00/chardet-7.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e9b31b9ae93872d66439b046a1e08c2ea99791f3c254dce1e2633e395c5587c", size = 1487945, upload-time = "2026-08-14T20:36:20.64Z" }, + { url = "https://files.pythonhosted.org/packages/03/25/9c8db4f951e974a4db5558d9eea62e1fd5b5889c9d0ad0315eed67c5cdb3/chardet-7.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa03322e07ac08d520ec50bb50c73143d0892d1adc067d4c5e58f4ef4b2363a8", size = 1510112, upload-time = "2026-08-14T20:36:22.248Z" }, + { url = "https://files.pythonhosted.org/packages/76/1b/59eb88a78d8f5855c27c25788088df82834ad067f3dddaf4d86ef03cf2d3/chardet-7.6.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ad9bc6dab4f338673353fa3f0dc96122f559aaf746087408106e2fcbf132fe8", size = 1462470, upload-time = "2026-08-14T20:36:23.557Z" }, + { url = "https://files.pythonhosted.org/packages/02/af/46c80c317f9b4dd61f15c33b1e073fbdd64d13c70fe19e34943071dc9e50/chardet-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:360260d074d8712ac1e9048fcafb0fdde246f9d0b12555748ad0017c5ecee43d", size = 1157092, upload-time = "2026-08-14T20:36:25.082Z" }, + { url = "https://files.pythonhosted.org/packages/6f/62/64da80dad0c804e743b4156f379183578f1e33918856ae928dc9248a6002/chardet-7.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19fea52164e6e00f2a21ed418f42e4b0162a09199274c86d07ad3efd661317c4", size = 1094073, upload-time = "2026-08-14T20:36:26.53Z" }, + { url = "https://files.pythonhosted.org/packages/44/99/934fb862d102c8756008597f4398323f32cef329f16e87fbb3bf76d4f4be/chardet-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a12023d48d0e207791c01161d03cb3c0d85c6a15f345eb9d3d56063a63d1e40f", size = 1071612, upload-time = "2026-08-14T20:36:28.067Z" }, + { url = "https://files.pythonhosted.org/packages/71/e9/b04e0ec576a77e79fe37279a9a5d5b1ae752d365e43df2eca0d0eee4cea5/chardet-7.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:249993b88ac7a58cad2781acea8f379152a28a719c9b401d614898c63a8c83da", size = 1489219, upload-time = "2026-08-14T20:36:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a2/c4d99299e9ce7fad561f8bb56babbbbdd3bb6b4fbd7c0ec674c1dbdd2cc5/chardet-7.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf0adaca8b1c4bacfade9d0a1e4f8f70b1bb122833d6f07ab90e3adc84eb13a", size = 1518293, upload-time = "2026-08-14T20:36:30.879Z" }, + { url = "https://files.pythonhosted.org/packages/56/1d/49f13052b74303bab2789d098063cbd19758217949ea54ffa216b6098cb3/chardet-7.6.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf6d08c2373b7772a558d141f9e8cee53fe1d222341bac612e4d558b04995f73", size = 1459077, upload-time = "2026-08-14T20:36:32.149Z" }, + { url = "https://files.pythonhosted.org/packages/0d/53/8da1f4758286efd8faf71356facddb382788ecf1bbd7c70d63e2e18a4898/chardet-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:406936df1328a3284fef366eaa2bfd1cccd0ef1b10cb99781dd5b022ea644b84", size = 1160778, upload-time = "2026-08-14T20:36:33.436Z" }, + { url = "https://files.pythonhosted.org/packages/a3/29/16a7419edfbd60e901e6a797cbc3e038cb2a81903bc16c029db755f0156f/chardet-7.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:57e6846cc13ce1ff59979f4ec9da770c57e12aa99046073f632de5a51d9a6f20", size = 1088449, upload-time = "2026-08-14T20:36:34.723Z" }, + { url = "https://files.pythonhosted.org/packages/1d/36/3a14b0f8ddeb302f157281ca656a3ce6874b78e2d6af03682f520b487245/chardet-7.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:089e3bb81a0a07e94f15461ded9f9ee66d349615b1a9fd557d4de1003e2fc12e", size = 1065126, upload-time = "2026-08-14T20:36:36.21Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4c/f59a39c2bfe4ac99baba8da842e8d2ea0b84dff7ba53a96e7ad8c71602d7/chardet-7.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43ea433e43a23c55e8e17f3fad1e07f5cfe5450c73124b95b0d849c21ad379ee", size = 1482492, upload-time = "2026-08-14T20:36:37.649Z" }, + { url = "https://files.pythonhosted.org/packages/bd/eb/93e8036681157f2217a18769927a035984526e6dbd5e91f28a375ca41c14/chardet-7.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b5d31f9b7f793e15e81cca877e7ccd72bffffa2a3443a9d47be9dfee84fad69", size = 1514185, upload-time = "2026-08-14T20:36:39.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/0066d7ab2c135e404a6fa166bb7fa49d1c7bf7af07b86ed95b1c48a348c7/chardet-7.6.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54b6a8d3b219560fa5cf4c28df932c37471afe047afdc152067104e741f38c1", size = 1454420, upload-time = "2026-08-14T20:36:40.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9f/e965f1d9eddfb86cf118f980d329cbee43c4ac4b562448b369a6b6ef36af/chardet-7.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b3b4c96c4df93899b3c8b9e8159e06b1f55c66d7ca384d91481108e251a06eb0", size = 1159067, upload-time = "2026-08-14T20:36:41.816Z" }, + { url = "https://files.pythonhosted.org/packages/cf/78/e14991c9487277ed7d006fff58f3084ac792ed94cced081788abb95df70c/chardet-7.6.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6061adf247ab5dda173b67010e13904c6071717660c7c8077fb50aca362b264", size = 1087678, upload-time = "2026-08-14T20:36:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/7a/40/0f95e04cb1820e0a582cd6d86bbf26be8302a94ccf330f8ba5f69735389d/chardet-7.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fc1e1571321baf8927582fe34363ad7f02279f11c8c2839c14b4c76894148db6", size = 1065310, upload-time = "2026-08-14T20:36:44.489Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a1/04404dcc9d6e1253b02583e562d2c7ddca50a7e91f460d62eff7e1d07c92/chardet-7.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8900f6c7cf6b015b17a51767cc6144689059ba1cdceaa383d29eb037ac28579e", size = 1485028, upload-time = "2026-08-14T20:36:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/f4/88/360064c4c7d9d0664561dae03b74c871d2f5332b329f5c99f1c997fb869a/chardet-7.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cedbc584789eb2edfde20fd03669972a833ce6019e60014ae613f9bfc440e8e3", size = 1510242, upload-time = "2026-08-14T20:36:47.206Z" }, + { url = "https://files.pythonhosted.org/packages/43/4c/302869fa1c69a5a41e4e78782680b12552ffd4286c3ae3c839b7b8df53d4/chardet-7.6.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5dc835e40e0e09c2c3eab43731a8b5127834f42786dda09ba2f4b699ccd527a", size = 1456456, upload-time = "2026-08-14T20:36:48.668Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/ff4fef15ed25fc3f945a3b981ae0f43c8559b3fbedb40267e59e583d105b/chardet-7.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:0f304de7041afaec0195ad6464937cd112392002e9d72ed15d55f20a9abd3a13", size = 1159103, upload-time = "2026-08-14T20:36:50.107Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/94e6f89485ce6c630e8fec6d388e3fa747e58b7246b0cc8c5c532ba98650/chardet-7.6.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a4f0a368ad04d5def08bdfaa17c7e15e71552f93923dc2aa9b2f7d9dee02fbb6", size = 1120302, upload-time = "2026-08-14T20:36:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/8e68ba12f13aaf4ee82d54ef8a1f83d62942e7a6bc1e579dd2d530a3e26e/chardet-7.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:75d6c3a4d2046d49e83d2d2206eb073a1f390743e856d90c1bbc19949b26acf4", size = 1093157, upload-time = "2026-08-14T20:36:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f6/6a35342b9efa69dcceab0ea8966571c6442a59c336bf460f0a2af95ed234/chardet-7.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:459e2b1c98f9a86a4698112aa42dffa802bbbff883c1ff144071f87224125862", size = 1521213, upload-time = "2026-08-14T20:36:54.812Z" }, + { url = "https://files.pythonhosted.org/packages/df/8c/8f09bfabbaedae45caa72b996e96d5e3f6436dfddc55c1311ffa2f6bd7d2/chardet-7.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bdb6f03107b7ace3f44e0edd91aa24456ee558787df265cc19daf45785b31c7", size = 1528427, upload-time = "2026-08-14T20:36:56.224Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3d/2540627b193112e08b8045f3cad9295570866208555aa6625b63cc69c6cd/chardet-7.6.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:0c44a32da32cc8b23d6b20d98ace15ec7600950e4955d1bf5ab1f849b0187fdb", size = 1087374, upload-time = "2026-08-14T21:04:14.994Z" }, + { url = "https://files.pythonhosted.org/packages/fe/de/dac4f550cde73c4732b4916e72ec489ae36933fbbd1697e4122d3ae2e9dd/chardet-7.6.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7bbc8a9652c7f859c593847f220c1d264f25749369abb1a267b404ee8cceb209", size = 1064688, upload-time = "2026-08-14T21:04:16.806Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/f4f3f288496797d2cf1d1e9036f920ed2b4c79787b21c42a3314f4a6d532/chardet-7.6.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83512a475a2f3886166aa0bca1bbb39343a4eb3186dd5532127d6f2591d09118", size = 1486320, upload-time = "2026-08-14T21:04:18.535Z" }, + { url = "https://files.pythonhosted.org/packages/c0/81/3ca30c16e6c6015b737fca22d9342957cc617d8a65329d3e963ad754a31b/chardet-7.6.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:271ab71ec1be61dbbce0436de0848895c03eae051c379e937a39573d9ce403d9", size = 1515374, upload-time = "2026-08-14T21:04:20.724Z" }, + { url = "https://files.pythonhosted.org/packages/6e/98/163a75b8b3372a6b6503f5f5a4154a222ac135c7a706b5d176f8e4b237a2/chardet-7.6.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da86fc1b40ff5996fbb5e4c2d2dca770eac2c893cef157dacc050b8b4d929846", size = 1469504, upload-time = "2026-08-14T21:04:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4f/3ad1b1bd27ae7f0d3d13cf711366525cd781a9e067950d8a09aa280916cb/chardet-7.6.0-cp315-cp315-win_amd64.whl", hash = "sha256:b73f277c1ac09c4f8076c4214b816c7aa78a0a2f0cb7156742f4303f856bedc3", size = 1158551, upload-time = "2026-08-14T21:04:24.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/92/22a609c68c5123ebdccd8fe1a80e423609012ce20166de19a2ed3955b2f7/chardet-7.6.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:61238d5945b36af9a2ad13494f8969b7deb3c3b4abe223e54670c064e73f5328", size = 1119186, upload-time = "2026-08-14T21:04:25.761Z" }, + { url = "https://files.pythonhosted.org/packages/29/35/75a90143e4200e197f4f6cf5895d379e22bc785d0c7711120df18fa5347c/chardet-7.6.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f2ec3c78cc6b54bf8e091ec4ee885473078b5d7ef18ab1b01c86ae1e98bf88f7", size = 1092596, upload-time = "2026-08-14T21:04:27.884Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2c/b6d5f47d878c46e04ae6fcb58e0969467925bc0819b333c682e0c41bbc5c/chardet-7.6.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:167d7ba3ee08b654e36d7b43ebd9a36606c9a12e2fabdb361757a095ca3b7e3d", size = 1516644, upload-time = "2026-08-14T21:04:29.624Z" }, + { url = "https://files.pythonhosted.org/packages/25/d2/2bc3f1066c6f27bc3fa3a98dfd8a2a9c1e969a9d6bdc1edcd35278791fc4/chardet-7.6.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f14f46ef1977e41ce1f4814ca6984cea7f8b6baf8cbc6626ef7bf3d13cf7ea13", size = 1533221, upload-time = "2026-08-14T21:04:31.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/6e/5a0b348fa4cd7847567a28c6e697ccf58391960bfd13a6e7473ee23ca2f2/chardet-7.6.0-py3-none-any.whl", hash = "sha256:4076d795897ce45239825956a1334e134322ecc4bfe84dbb12acd5390de0fbc1", size = 680279, upload-time = "2026-08-14T20:36:57.763Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "graphtty" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/b3/0756e1b1e46b61a4db4a463a76ca113e62113742f97ac6cf383dd05d6a97/graphtty-0.1.8.tar.gz", hash = "sha256:069cd84764cc64d414928451071fc9c97c05becbf564c2f0278691a6451a8240", size = 638011, upload-time = "2026-02-15T12:47:17.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/a9/66d01580a4a92b576c056e9967d552a28ed836540eaf73b436474c514bc2/graphtty-0.1.8-py3-none-any.whl", hash = "sha256:4e19e6d66b9ef79e2715377163f61a6542b5b9ee00d50406b80a40d0ba094f67", size = 25474, upload-time = "2026-02-15T12:47:16.377Z" }, +] + +[[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 = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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 = "mermaid-builder" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/57/d83e7e62de05adbba2476b7596ed4a253907a4f4c19be74c40e7ce2944dc/mermaid_builder-0.0.3.tar.gz", hash = "sha256:3016ef25668bcb28de4a4746aabdfaa531c06a7e7f89fa1596e6a274c7c73705", size = 7183, upload-time = "2024-06-14T17:14:51.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/4e/7480454b7ee7de5c2c8561e83b4564196ecf1c9847e893dfbb51032e5a3b/mermaid_builder-0.0.3-py3-none-any.whl", hash = "sha256:decd7d25268bda7a520ce0cab7873e50fac1e33b41e5a1dd7daec5c0ae3c7ffb", size = 6696, upload-time = "2024-06-14T17:14:38.48Z" }, +] + +[[package]] +name = "mockito" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/8e/eff6dfa2c597ddc09d2c83a922f5154f00fa8bda90f68eda984a6d7506bd/mockito-2.0.4.tar.gz", hash = "sha256:d341f228c5b5ccf3c473751f6c6f09d8d83c5da49846a6ee31eb60b65607e0ef", size = 218582, upload-time = "2026-04-15T09:23:47.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/70/625563b5925ee5393a971f4790aab4b0059deda3bf9196c57ba7172e9ab3/mockito-2.0.4-py3-none-any.whl", hash = "sha256:04d7e6e9b9b7288e76235b894f9b3cbfdc9cfc631e548ed607933146ae9db1f5", size = 51921, upload-time = "2026-04-15T09:23:46.68Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/e7/bb605a7bab2d8425a64b3fa762b39dc1bf1c7e3f11ba6fb5413d6db0ff8c/msgpack-1.2.3.tar.gz", hash = "sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186", size = 196517, upload-time = "2026-09-29T02:33:52.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/95/b9c651ccb9d720b2e2c8d537954dff528ab869a03bf89598145716db823c/msgpack-1.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec90a9ae3e1169fa1171147340f0e97d941aa19fcd3b34e8339a55933ed042af", size = 90404, upload-time = "2026-09-29T02:31:44.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/cd/fc9e2e367e80f1493e2ec5f610dda558b344eeede296f88976db133e8f2c/msgpack-1.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9d7e9cbb0998bbfd363fd9a09c330520d5e9cb323c05b5a1a05865d23ccf2226", size = 89683, upload-time = "2026-09-29T02:31:46.413Z" }, + { url = "https://files.pythonhosted.org/packages/19/9e/1028485c6886c1c117f777cc9b053e541eff0fedb3292dfb1da95040edb5/msgpack-1.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6707d2fa2aa1bb5424ea0b05f44ffc989b15ab41a73ff5855bff4944fec7c8ac", size = 465347, upload-time = "2026-09-29T02:31:47.934Z" }, + { url = "https://files.pythonhosted.org/packages/aa/83/800570e6a22376eb8d599920f70aead4779a63611696f567477c4e85a70f/msgpack-1.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:382b219de3d436de3baba0f4b0c6d4336e8f5858d0eb047918b13b69a71c6c55", size = 477820, upload-time = "2026-09-29T02:31:49.479Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ff/817e4a2052f848d3fb67726908d6e4e7c19f68ee7c19553a82ce7b0ed415/msgpack-1.2.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:186e6c602b8a9968b8e864c67d622a69279f7d1e55ae25f40e3bff7e815b2b62", size = 436656, upload-time = "2026-09-29T02:31:51.18Z" }, + { url = "https://files.pythonhosted.org/packages/3d/42/040cc55dde6a7d92057baac8d1fc9cfb9f4fd4162900e2ec16dc33917a7d/msgpack-1.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9276ba88891338f2617044429dfd080ae008c9868a25f6f1a7d004a35dc9ac0a", size = 460939, upload-time = "2026-09-29T02:31:53.026Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/4dc007bdef930eed247346773bc0189b710078961d3218d5ee7ba59f322c/msgpack-1.2.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c942c21a93f36b3a69e828c8945bb72c94dc2ffe488a2086950c812f3edf046c", size = 433608, upload-time = "2026-09-29T02:31:54.981Z" }, + { url = "https://files.pythonhosted.org/packages/c0/97/a1b944046f283ec89445cb2a982c42233b5b07cc630f9be739f4f1d469a3/msgpack-1.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18a6ed513023001b28dcd3ba54966f6bb90a38274ba8d2640464bcab3a1b81d4", size = 477373, upload-time = "2026-09-29T02:31:56.713Z" }, + { url = "https://files.pythonhosted.org/packages/59/79/ab411d0d172743732ab2503f4c32a22dd1a7d1436a6feecbb160e4b6376a/msgpack-1.2.3-cp311-cp311-win32.whl", hash = "sha256:d0238cd05dec9ffbe0de1071df685ba63e30a36ac155285b1a094e727c38cbe9", size = 67514, upload-time = "2026-09-29T02:31:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/63/8d/6f0cb2b84e484e96278455c26870196d025bb0cec312b226a663f1fa9000/msgpack-1.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:30e1522e4173230dca4d9ad896f038f73c0da6c1edd42f4dbad88ac583cf5d46", size = 75850, upload-time = "2026-09-29T02:31:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/aa/25/f99e13a2c1d3f5a1dcaa5aab27f474e8c4358188bbc68ad79fecb0d1aefe/msgpack-1.2.3-cp311-cp311-win_arm64.whl", hash = "sha256:8ca67f77938ea6a3663aa9bd22b3e031f6da84d665be850abab910ee90728dfd", size = 72338, upload-time = "2026-09-29T02:32:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/af/12/4d7c6d6203416d9fbf0f59ebaa805e70fb929b93a41b611bc821ec5964a0/msgpack-1.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89c930aece4e972b208ba589c8410b4167b05e411a5ea2cb25fd96f8bc47ee43", size = 91577, upload-time = "2026-09-29T02:32:02.141Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c7/8576ad39f4ca42ddad26f68eb8621d2d0a60501193d480f504bd9d7f36c4/msgpack-1.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:905a189853d6bdb204c7ae5f4ab77fb857448abfff574d3d93c62e2815b24b4f", size = 90027, upload-time = "2026-09-29T02:32:03.508Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3a/aa9c580aea1314529a0f3562461479780b0d254b064f0880956bfbcc74a8/msgpack-1.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7b3d0018746b5997dd6b14a1870b07cc4c327d9101145d94a1fc264a51a06", size = 460343, upload-time = "2026-09-29T02:32:04.906Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618", size = 472998, upload-time = "2026-09-29T02:32:06.69Z" }, + { url = "https://files.pythonhosted.org/packages/7b/41/915c81fe6df2d3cbdb0dece4f1a5cd313e1cd2abd9f501d0f50c0582517e/msgpack-1.2.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:666ef5601ab0e6e345e47febc96aa81143cc932201543480cbb9499164f05ffb", size = 423216, upload-time = "2026-09-29T02:32:08.739Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e7/7dda8b1039abfd9bba4c5068172c67135c9e33089f503512db9226f23c24/msgpack-1.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87cf2ef05ff2f2493ba29fcdaef27e960ca64dacfd13460ae29e6f92e0ed05bb", size = 451218, upload-time = "2026-09-29T02:32:10.517Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/ce995c1ed4a0522b7f2d034bc2034fd63005f240b945961b70fb56fbaf3d/msgpack-1.2.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b774ff994d844e541439ac5d2d49a14def4104830c3465e9394c153f86200ffb", size = 422453, upload-time = "2026-09-29T02:32:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/ce191fb87e2650d0166b34c437e499ee4a7f9db9c1eb164f41725eb6160e/msgpack-1.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaf7e82249837e3aa97297b34a0bb9ff562027381631e057cea6e1367f10b438", size = 469003, upload-time = "2026-09-29T02:32:13.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/539123407fe200fb16609c835675496fbeb6017ace9fc93909f0613223ae/msgpack-1.2.3-cp312-cp312-win32.whl", hash = "sha256:7c047250096f9fc19dba26e3d1639b5e7a84114003605c94def667149a70ced1", size = 68303, upload-time = "2026-09-29T02:32:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4c/331b45f9b86fbda6b9e103244d189068e51f726d8c40021ed66e1f2c415e/msgpack-1.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:3ec409b0d6aa8e9eec6eaf881b893caa215dbe68c5319ca96e8a271d81bb111d", size = 76744, upload-time = "2026-09-29T02:32:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/13/9f/fb572dc42b9fac06c7ea848aaee6e140d84469743bd1402bc07089fc4566/msgpack-1.2.3-cp312-cp312-win_arm64.whl", hash = "sha256:59612b4ed48a04cf024584218e813562f3b30a3bafa5f55abe300b15da314751", size = 71580, upload-time = "2026-09-29T02:32:17.617Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8b/3824d65e912e925d09ce30d9130fa9970d6d2855d7888b13639a6604967f/msgpack-1.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8", size = 91728, upload-time = "2026-09-29T02:32:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/df7f2c9ebb94760113debbcea2bd3afe5fdab88a4f7bec1b618755517460/msgpack-1.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709", size = 89955, upload-time = "2026-09-29T02:32:20.224Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/e5fc57136e8bacccb2b39627dea2cd546540a06181e22fe6db90e15b3ae4/msgpack-1.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca", size = 454930, upload-time = "2026-09-29T02:32:21.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/30/c394d37898db9212d1693456cdf363c7e1a097d0b63e10664007f3df3ec1/msgpack-1.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb", size = 466866, upload-time = "2026-09-29T02:32:23.742Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/1e4ddf6f6b829b3ee6c530c79dfae89cb609d2b0eedb5e0ae716851c52d1/msgpack-1.2.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5", size = 418715, upload-time = "2026-09-29T02:32:25.262Z" }, + { url = "https://files.pythonhosted.org/packages/11/a5/f460ba6d7a12d4301002f3efbb8f841e8bdc9c5fc98d771689677a352885/msgpack-1.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37", size = 446489, upload-time = "2026-09-29T02:32:26.988Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/adface88db909bed321c85dd673655152d4a514c67e1f0800eb51c777d07/msgpack-1.2.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d", size = 416998, upload-time = "2026-09-29T02:32:28.606Z" }, + { url = "https://files.pythonhosted.org/packages/36/00/5bb3a239ccfc3763c4d0fa49b13b1b7010b00182c499ab3c1fecfe6294bc/msgpack-1.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853", size = 463288, upload-time = "2026-09-29T02:32:30.375Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/456df77f00d701df9d6980ffb80291bce6e4e2e112e25a4dfae216f0715a/msgpack-1.2.3-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890", size = 53347, upload-time = "2026-09-29T02:32:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ce780be666f89b77cdb855daa9ec62e87bb7f69e9f403e4a5d83a2b2208f/msgpack-1.2.3-cp313-cp313-win32.whl", hash = "sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f", size = 68258, upload-time = "2026-09-29T02:32:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/51/06/c3def9bc4db283103c5901b302ee2a4305cb1e69729244f94d9bd8f8e8e7/msgpack-1.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a", size = 76569, upload-time = "2026-09-29T02:32:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/12/9f/cef344073858b80adb92d6ea342e20b0eae7a8f6fe70281b69cf03707270/msgpack-1.2.3-cp313-cp313-win_arm64.whl", hash = "sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047", size = 71530, upload-time = "2026-09-29T02:32:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8e/f777f74e38731c428857933c8011596f2d2f3160c821152f23b6ffba862f/msgpack-1.2.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8", size = 92042, upload-time = "2026-09-29T02:32:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/551608543ee5d590f7e8d522267665d6d9946866ad2a2a70a770f7c70793/msgpack-1.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4", size = 90578, upload-time = "2026-09-29T02:32:38.883Z" }, + { url = "https://files.pythonhosted.org/packages/ea/11/6d78ce5a9a58bf9ba7b1b6a8f649173b030e6770c8019cf330b91825ee5d/msgpack-1.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220", size = 454352, upload-time = "2026-09-29T02:32:40.34Z" }, + { url = "https://files.pythonhosted.org/packages/3d/08/feb9a196269ba7809f44f9117d9e4a601c41c313f6144fd0c337293a5488/msgpack-1.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58", size = 462562, upload-time = "2026-09-29T02:32:42.176Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/3a674f366def24140b103d1ffd4fd27b3d912a13e47da67422afa16bebb3/msgpack-1.2.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620", size = 418134, upload-time = "2026-09-29T02:32:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/82/944e71f280577490d99a3951cbce21aa4cbe04e7ab42cb373fd668af883c/msgpack-1.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30", size = 445937, upload-time = "2026-09-29T02:32:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ec/feddd629c4a3edf1395313680450c525086cceab56dec0d4de9da9ccb618/msgpack-1.2.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c", size = 416450, upload-time = "2026-09-29T02:32:47.558Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/263a10f8c4613ba0713f48cbda7695ac8dd6d6fab2fcbc9168f03f23a94d/msgpack-1.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207", size = 459546, upload-time = "2026-09-29T02:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/1e/21/addcfa1e583cfc8a22fbdc57526621b5decd7ad676ae12e9150b7be1be5d/msgpack-1.2.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150", size = 53462, upload-time = "2026-09-29T02:32:50.708Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2c/3cb5c8524a1335ee27ca952c7ab78d375a16fea8e18ae3767ba0c880416c/msgpack-1.2.3-cp314-cp314-win32.whl", hash = "sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec", size = 70294, upload-time = "2026-09-29T02:32:52.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/9172ff3cdb85d160ad06df5e2708a5fce7682982a5eee8d31869b9f69d2e/msgpack-1.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab", size = 77778, upload-time = "2026-09-29T02:32:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/04/e8/b4c23178bcf605ae17cec48a75530dd69d49b0a5a6f5f4df5c47d59f746e/msgpack-1.2.3-cp314-cp314-win_arm64.whl", hash = "sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290", size = 73794, upload-time = "2026-09-29T02:32:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/66/b1/92704be352c4f428b7e0a0e0fb210cb1aa2b1c42c102b8dc22d34b82fac0/msgpack-1.2.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1", size = 93721, upload-time = "2026-09-29T02:32:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/49/78/9c91f1e86cadcbc100b3780fd429c3715648704032a612e77a00646ebe79/msgpack-1.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18", size = 94256, upload-time = "2026-09-29T02:32:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/91/4d/270f9725921ae88a29d37a774a77ac24f0ef1411fc960a63f5a4665e81b4/msgpack-1.2.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f", size = 471673, upload-time = "2026-09-29T02:32:59.886Z" }, + { url = "https://files.pythonhosted.org/packages/48/b8/eaa8d930f72dc1d1dd79511dc2ccf965922b059f2f0ed3b30aebac8c4b11/msgpack-1.2.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a", size = 466257, upload-time = "2026-09-29T02:33:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/97adc805037bc7e24c4e2f711bbcd3b28be8ec9aea3e778f18208cfbdb46/msgpack-1.2.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc", size = 418484, upload-time = "2026-09-29T02:33:03.402Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7e/1c53302606fe436ab48ba539ebafafe4a6a9efe12c4f04dc7eb36912d93e/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f", size = 454064, upload-time = "2026-09-29T02:33:04.977Z" }, + { url = "https://files.pythonhosted.org/packages/00/2d/9ee0170f638907b396c15c6cd26b3e54f869159efc6206683acfd8f696e1/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e", size = 417901, upload-time = "2026-09-29T02:33:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d2/905c84490a75cd15a27065407cd085d201f7d392e1e0411f49f03fd31ade/msgpack-1.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db", size = 459896, upload-time = "2026-09-29T02:33:08.361Z" }, + { url = "https://files.pythonhosted.org/packages/37/cd/4ce5809b9ab3b114d7cca64863e436820fa1614b49d55ccb93d49824ac2d/msgpack-1.2.3-cp314-cp314t-win32.whl", hash = "sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e", size = 75983, upload-time = "2026-09-29T02:33:10.023Z" }, + { url = "https://files.pythonhosted.org/packages/8a/31/853bb580744c24be0dbd8b090c3e6987dce466a1fc840fe50c0ac2ef9044/msgpack-1.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9", size = 83757, upload-time = "2026-09-29T02:33:11.441Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/9f1b2ee484414eef9e21ee2b2b23b482bb71433ab9bac1da03cbda15ebf5/msgpack-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd", size = 78128, upload-time = "2026-09-29T02:33:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/47/b8/50db4235407c3802f622b4ccdf65c6fe1e48d3c3eab6981fa6a9a5e53f11/msgpack-1.2.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c", size = 92111, upload-time = "2026-09-29T02:33:14.476Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/50cf2a45c6163edafd737e2fd555103a26ce6748e1e241fb56ed445ea835/msgpack-1.2.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949", size = 90583, upload-time = "2026-09-29T02:33:15.924Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/8cc02f767c3bc94d2649c954d28dea935ce9398eb9c93ce2444bb9474cc1/msgpack-1.2.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5", size = 454751, upload-time = "2026-09-29T02:33:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49", size = 463597, upload-time = "2026-09-29T02:33:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a5/e7c261abf75783c07dcac89951cb31dd0c123bf02fbdeda0c67303e698d8/msgpack-1.2.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab", size = 422661, upload-time = "2026-09-29T02:33:21.093Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/466d5133f9e1c2e232e15e304f715b62f6f0e28332d18e37d975fe174315/msgpack-1.2.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012", size = 445188, upload-time = "2026-09-29T02:33:22.877Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b4/33e7ad987ee2f4b3d449a6cbf28f574ed222987ca7f65ad277072646ac5e/msgpack-1.2.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377", size = 420451, upload-time = "2026-09-29T02:33:24.485Z" }, + { url = "https://files.pythonhosted.org/packages/34/2c/9d8be0d6c16e7e6131cd7da20257dd3da65473e3e6df0c00572fb10a195c/msgpack-1.2.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd", size = 460624, upload-time = "2026-09-29T02:33:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e7/3a04783582c6f44f398cbfcf5f07a111192126ec4e63edf7f5640143bf64/msgpack-1.2.3-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098", size = 53474, upload-time = "2026-09-29T02:33:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/db07359851644e258609d84f8e4fe0030ef448c108e20afe73f2a3bf539c/msgpack-1.2.3-cp315-cp315-win32.whl", hash = "sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0", size = 70344, upload-time = "2026-09-29T02:33:29.382Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e4/cf5584d2f2a2e4465d5896a855a3e75a34a20ab172360b3d42ad862dd1ce/msgpack-1.2.3-cp315-cp315-win_amd64.whl", hash = "sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a", size = 77800, upload-time = "2026-09-29T02:33:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/63/f9/518ad4e8a580027b507eafdd26de7aae661a714e43d7c111c212482e4a1b/msgpack-1.2.3-cp315-cp315-win_arm64.whl", hash = "sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d", size = 73871, upload-time = "2026-09-29T02:33:32.406Z" }, + { url = "https://files.pythonhosted.org/packages/a4/79/254d4c9ad642b2a3ba84e646787892b34cc815eb36c9976f67a1c4f38515/msgpack-1.2.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124", size = 93370, upload-time = "2026-09-29T02:33:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/5a2ba167646a25e84eaa8894e12935351e4331b80c28a9237ce6fe8d375f/msgpack-1.2.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173", size = 93959, upload-time = "2026-09-29T02:33:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/2b44612e55f7cf5d5e4b580294959b4429bbbcb1991177888e3e18668137/msgpack-1.2.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007", size = 467921, upload-time = "2026-09-29T02:33:37.023Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6e/3309798ed1c11d7fcfdc7b946642685b0ff1588477925bc0d26bee7dcaae/msgpack-1.2.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e", size = 467310, upload-time = "2026-09-29T02:33:38.799Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/9c799f489fa4146de4e00cfe9fee17afe33d8012f88ddffffea94f7c4700/msgpack-1.2.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6", size = 420178, upload-time = "2026-09-29T02:33:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/5850dc9cafcd2ea315692e65db0e222d20923dd55f44adf35061003de27e/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0", size = 450248, upload-time = "2026-09-29T02:33:42.366Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d2/b4c806e3497fe21f0b353568266aec14ff735d092aea672de7b2955db03f/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471", size = 418431, upload-time = "2026-09-29T02:33:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f5/f4ecc3ddac4d551bf2f3cdb283ec546dcc826fe7c500074be61aa273e08a/msgpack-1.2.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa", size = 457543, upload-time = "2026-09-29T02:33:45.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/1c821d8386fae5cecc5fcaacf3de3947ff0a23f16bb481b5532b5868372a/msgpack-1.2.3-cp315-cp315t-win32.whl", hash = "sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a", size = 75820, upload-time = "2026-09-29T02:33:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/41e2f7343a3764a9c1fb10c79f9a6a05db9df93dedd76401d1b511f5a685/msgpack-1.2.3-cp315-cp315t-win_amd64.whl", hash = "sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3", size = 83345, upload-time = "2026-09-29T02:33:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/0c3aa439bc7a7bf24684fef3a0ad776cba170e18ed94445e723bce42fce7/msgpack-1.2.3-cp315-cp315t-win_arm64.whl", hash = "sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e", size = 77572, upload-time = "2026-09-29T02:33:50.729Z" }, +] + +[[package]] +name = "multidict" +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[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 = "openai" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" }, +] + +[[package]] +name = "openai-codex" +version = "0.161.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "packaging" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/88/34bd640f2663690fca9f8f7c38dfdd70c89c9ef30c17ea6dfde85c5e6e97/openai_codex-0.161.0.tar.gz", hash = "sha256:c21bce7ba545f0715dde58fbd8152e41d293fb103c043a37d10f437b97c6039c", size = 89423, upload-time = "2026-10-07T16:11:48.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ac/f012ba67f800599e5f3452e4649fd3498dd928a235eba01067471b6f20a0/openai_codex-0.161.0-py3-none-any.whl", hash = "sha256:41823fb522572bcbd5acee7123947ba81cebb52d2a580ba81d7eb60c69e9b237", size = 97577, upload-time = "2026-10-07T16:11:47.231Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.161.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/01/4d15de5fed736d5faf417fc4ddabd86cf46ee7e3599de0a21a151a0f5194/openai_codex_cli_bin-0.161.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:a0d046263927595bd0f98e18047dc2641bed388d449472151ebc5f8d0352868b", size = 134290593, upload-time = "2026-10-07T16:10:18.42Z" }, + { url = "https://files.pythonhosted.org/packages/d4/44/878b1f0d0f8fbc5ccf732d5dd82ed3cc3ff2b8c0afba0752e355407cf877/openai_codex_cli_bin-0.161.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aacd171c64015ee5b0ed8ac7f441ecf8bff78539a3a3dfb85c3fb27caaaf3ce8", size = 123456946, upload-time = "2026-10-07T16:10:25.367Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2d/5dd0d722d1c6be7f7d202162ca8c40e1762d00cb13487bb8b0ff1bb2244e/openai_codex_cli_bin-0.161.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f3099cf840ac9ac052d15c45e590a4fb8f54f093093340cba6b9428aa8b41bfc", size = 131213946, upload-time = "2026-10-07T16:10:32.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/4d/74c99f4cad9dde4e7d1c9aa2d3c332e95a54a63b8d1ffc4fdf2f3f070eef/openai_codex_cli_bin-0.161.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:c81b8d4ce997d947d2f558d81934a6a199e24471162648e5021677b6ee593030", size = 140735531, upload-time = "2026-10-07T16:10:39.833Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/b2754568ccf628eb06be80dffb7d758da8fd4097b8c730bf832013c32452/openai_codex_cli_bin-0.161.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:4e545ba731de3198b040087576f7ded0e3c9c2b6758a55962b026ff289f58cbb", size = 152354279, upload-time = "2026-10-07T16:10:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/88/7d/96e7deb658cc80e27b321f488f992d3ed39e8c0f4257f28f3895123a6a50/openai_codex_cli_bin-0.161.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:7b4665f72183e4860debc05f46ba9f2fa0f9f2564b0e41422d02b2a374f409f2", size = 162147990, upload-time = "2026-10-07T16:10:55.225Z" }, + { url = "https://files.pythonhosted.org/packages/45/d0/f7336ef5d284a9de6049204bbfc0d1833d00ddb5066bf92e0d3634813ca5/openai_codex_cli_bin-0.161.0-py3-none-win_amd64.whl", hash = "sha256:555544af2cb833a69a501906c6e3f79b613b7b909281202f7f4a52d316e342bd", size = 151107195, upload-time = "2026-10-07T16:11:02.776Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/1c5748e8c6b482d37c402fb4e24de2bef933765e55d7d8e8d62bfb670a18/openai_codex_cli_bin-0.161.0-py3-none-win_arm64.whl", hash = "sha256:2c2a043c1fc67c4f6bf7d14f4dfb7998cc08c45d1f49a4bf1babd627e0c74c14", size = 139878792, upload-time = "2026-10-07T16:11:11.302Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + +[[package]] +name = "orjson" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/72/380b97dc45bd162d23afe5194721ef678d9eac7cfaa549fe2873f7f0a518/orjson-3.13.0.tar.gz", hash = "sha256:d1de5eb04485110c5da4c657e49168995d55e076b1ce60f1a042e254f4186c4f", size = 2732604, upload-time = "2026-10-07T14:09:25.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a3/0be3b115907fea61ed340639fb0e1562cd18969bad5b3f486f808197aaff/orjson-3.13.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:948bad47f2e2e43527f14248364a0e5dee26dd3184691010ec4a1ebeb0fd6771", size = 223146, upload-time = "2026-10-07T14:08:06.474Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/665935edb16163f8b764182e29a30cf056947a66893ed032191e5f01eb3d/orjson-3.13.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1807c2fa49d393c7ee95fd1ef1b39cbb24aa3ccd81f30b84503ba59407666960", size = 123546, upload-time = "2026-10-07T14:08:08.324Z" }, + { url = "https://files.pythonhosted.org/packages/67/ec/e7cde480c0e212594d17ba2b2bd210c002052e9147fc1a1aeafaabe722fb/orjson-3.13.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:637dbca1fccffe83780e806fbc0f17427c0c59bf822528eb0acc8f0aa9f19acb", size = 113290, upload-time = "2026-10-07T14:08:09.816Z" }, + { url = "https://files.pythonhosted.org/packages/36/59/4455fb11a297af73611dfc437f0f89456220227ed1cb1544a5a0ee9d6c03/orjson-3.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:554948becd1110123ef9f6a6e1310fd92b2d07d2cbac6dbf65df3de75702e736", size = 130342, upload-time = "2026-10-07T14:08:11.253Z" }, + { url = "https://files.pythonhosted.org/packages/ca/80/0eec5fbde2e52407646b4cb3118f63175bdcee1e2390c2759dc96e0bc62a/orjson-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9d9a101bd8dbfad112170f009cd155e52bb8c936468821a0d03cbb96c0e426", size = 129138, upload-time = "2026-10-07T14:08:12.814Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cc/c0874f13819ae346d69ca00d074d464710b494abd4442bdebf75ac404a98/orjson-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89bcf2d4bc6c9a7e1763c8cf534f38712e66b76a0fefda7fb7785462f0d635e4", size = 130518, upload-time = "2026-10-07T14:08:14.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/ab/140dd9adff84bf64b862c4fcfe2d055af6014d5ba03a075f95c9addb2ec7/orjson-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a79cdc4934fe81f593072c94e13da3095e9d41c2deef8f6ff2901794ca1c5042", size = 134924, upload-time = "2026-10-07T14:08:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/08/0a/e8f6deb032b1d98a39043cf99b863d8b9e842e2ffc2d2067d2e2a88c18e4/orjson-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50a5202ba388b3850ba24437951727d3aa6d79a21964a30ae8dc6a059a5fd34c", size = 126704, upload-time = "2026-10-07T14:08:17.439Z" }, + { url = "https://files.pythonhosted.org/packages/af/cf/be64b99ff75f7983488390d4ef5df72115119770eed295691c0a715d492a/orjson-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0377d6962fa431c93ecd78fdea771bb62ec545b24ee0c5d4e32acf2260af259", size = 121287, upload-time = "2026-10-07T14:08:18.843Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/1b8ca186baf3420f12db1f2819fcc5f2cae69e4cf051168501726a64c0fa/orjson-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d84820b2ec4ac975cba482214032de5b0dbdd17046170c98e642ef9c4a4ee4b", size = 126314, upload-time = "2026-10-07T14:08:20.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/ed65f84ed5ed6a1e06eb628611b4172e7480fc4ad92594856751a6363cac/orjson-3.13.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb8644dc6d705e1269ed2842bf4dbe2b4e50d670de503bf79d5cef3a5148a4c7", size = 223063, upload-time = "2026-10-07T14:08:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/9332eb96d2e379384be0f211f543835eebc81f460c9403b84abe1294c431/orjson-3.13.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6ff2a2c67f35202f7d823753d38ad371a9b7fc297567cdfff4420e763cb9f6f8", size = 123364, upload-time = "2026-10-07T14:08:24.026Z" }, + { url = "https://files.pythonhosted.org/packages/b4/06/558456b7da27e974a8c9ea09117b07119f6fa131cd62b8b9ecad9eea94e1/orjson-3.13.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:65c4e0e106ccc7265b488385659117a6805c37d042f737558ecd68aa0c67ad8f", size = 113199, upload-time = "2026-10-07T14:08:25.476Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/1187a9c09965620348262ec0f406868f6d7c234b2e9b5ee51020bdde5748/orjson-3.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:fbbad6b9b1da43f25c1f5b20cd5a268e028a2fc95d5a8d1ade6059973bc71584", size = 130329, upload-time = "2026-10-07T14:08:26.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/07/5d1a151bc11600434fe799e73abfc6a4d463d02e149a20e47c59d3a985ae/orjson-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d895cf7bbfd50ef34bb63bb727b14514f259f3e3f8dd010783bd38e864c6e", size = 129072, upload-time = "2026-10-07T14:08:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8c/bb07c368abbf4021c4cd01c12edb526e00090f7f750ff1b88da6e6b6c7a6/orjson-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bceadfd314bd238f584fc229a4bbaf0e573597e7a026dec5429fbf29fd66c641", size = 130612, upload-time = "2026-10-07T14:08:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/4b66d19619ed344ac000ffea7c006477d0061d580646e736ef0e203759e8/orjson-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b74c30e56346aad067937d766846ee74c231d1d18aad3f324e9b9261de3b2d5e", size = 134632, upload-time = "2026-10-07T14:08:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/f8221f6593e37eb26ec4706e185b9ac6f38ff0c8f7bad5459844031ffd2d/orjson-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4329c19b8a25693f60a77b867c9d2a3ab637b20e36f5b7bea7f5acb492b44b15", size = 126807, upload-time = "2026-10-07T14:08:32.914Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/a1ca7321eeafd7d72e174cdc388cc96301f41516d863e7b1f64f0a1735be/orjson-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:b571236d8393edcd3236e07423f762bfcf571f852aad667a3bce9e7b755e0790", size = 121538, upload-time = "2026-10-07T14:08:34.325Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/1f19b4779c910104370932fceb9ed436b47ac077f297db74008062525c04/orjson-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:8594956a75223f657e1e68c568c0eeb3dd145f02cd6b78a47fd9a8095dbc4eae", size = 126259, upload-time = "2026-10-07T14:08:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/a9/56/f8ad2546150168858c16915c452b00eecb79597597524d1ad6ae14ad4eab/orjson-3.13.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:64e8f345048d988c8b68d3882e5d41028fca1219a9939b32e4a77be34c8ae8e3", size = 222892, upload-time = "2026-10-07T14:08:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/1f/19/725d23160b2471a3f27026c55bb79af34687652d8be8f5f583cee5dcd42f/orjson-3.13.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ded33b972cffdaf4ca0ac917338ab61d2bb10d68987dbcae641c313fbfdbf499", size = 123319, upload-time = "2026-10-07T14:08:38.989Z" }, + { url = "https://files.pythonhosted.org/packages/ac/08/e5d81a00b22c73dfcb60d80da3bd92d5a7684346593536565f184dbae3c9/orjson-3.13.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:45e34deb3437509f4ec9888dd9ee5dc426cfe21be10f1eb4ea3a9e4d33034f9e", size = 113196, upload-time = "2026-10-07T14:08:40.383Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/fda6117c69a43e470b1e9dff38dd8c5f0bc6fd8a47e4d4561ab023039335/orjson-3.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9825b954155b345c4759f24e5f8d652b9aec2261bb5d4e1abe06bba0a1200535", size = 130245, upload-time = "2026-10-07T14:08:41.878Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/d0cfebd456defb234414795ae7599696bf124843dfe077d0c9ece0c93554/orjson-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b081f0e7b600ff24513dec4ca75507fa05e904607847e386e8310d5b7b96b6c7", size = 128981, upload-time = "2026-10-07T14:08:43.716Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/f8d83189ff5b7b2ff225a58c5908618cc4e86afe09e65d17a30ac68c9da4/orjson-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbed5f4c4b88d94bcc36115f4c3bb3aa25da1563a5c3328aa3acebce2b083040", size = 130370, upload-time = "2026-10-07T14:08:45.132Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6a/d6344c305003ea826b3fa0482645a897a3cd6d477ed74e1fe15d3322cb23/orjson-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9b61676116f755126b90e740a9cff36b91562f47ec330056cc88cc3b9f02f4b", size = 134595, upload-time = "2026-10-07T14:08:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/d73fa44f88d53e02d10de1cf77c16ed13204ff5bca47e1692da6b406619c/orjson-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ef75ed7e81dae34a3649f82df52cd85f9ac839a7d6ec78ab355b33b3b27ef7f", size = 126513, upload-time = "2026-10-07T14:08:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f8/bcfc50b4ab851c4f9c0ee62f52bf3b28f0bcd0d9fe08e0ad98d4585148db/orjson-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ee06e53b998c71ce3eb93b86222912fdd9dcced685ac64d4525d36fac338ea4", size = 121371, upload-time = "2026-10-07T14:08:49.549Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7a/d6927845712ec2b1e89263cd12d7203531db185dbad67f914226f2fca156/orjson-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:89efecad02515df7f318d0613b5dfd6d2a1acd323a2b8294712789a715945525", size = 126134, upload-time = "2026-10-07T14:08:51.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/10/98b5a3cdc086abf78d8cd20bb0cba124485d4b6a745722197bd209d967a5/orjson-3.13.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a7bfc7db961c7d96cb75889dc6a1e4ae1e91d87ee61da564f582bd742b8dfeef", size = 222889, upload-time = "2026-10-07T14:08:52.673Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/7728c5280ab5202f4891ff4b0b96e2e1dbd5520dfee53edf083c54409a64/orjson-3.13.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:91d933e668ff0ffe164d7c2daec36beba6d1ce7fadb71538fbe142a71f8a1e6e", size = 123312, upload-time = "2026-10-07T14:08:54.25Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a5/d9a44321e6f66c0f64b45be587395f87ad94cb447bce7d92286f6b97d46a/orjson-3.13.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:6c8bfe728b81b0fd58a3c7f3f9c5a113f87f2992c9948e0f28707aafd737c0bc", size = 113146, upload-time = "2026-10-07T14:08:55.803Z" }, + { url = "https://files.pythonhosted.org/packages/80/da/d95c80d413f288feb471e16d82e5c1512d2439728e3bac917d058c31f098/orjson-3.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8e05549f3b30f9d8a8e28c5aba11cc2a4b90b90961ec685ca58444b0815fc09", size = 130348, upload-time = "2026-10-07T14:08:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/04/0f/36fdfb32ad1852997bac00e3ce52c7888d8a1094ba9dcdcbb22fcc6b953a/orjson-3.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c749ab3ac30b5ab1ffb7677f8b92eacfdfdc5260210baa398f845bc3714c05d8", size = 128971, upload-time = "2026-10-07T14:08:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/25/de/a82acf93bdcca0c79ccff25ef0c6868d24ccbc2e72f21fae39c8cabce4f1/orjson-3.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58a9619d88f8818d9ab6b39d70d203789457ba13c1ed5d274f33ce9ae7e81a36", size = 130359, upload-time = "2026-10-07T14:09:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/71/ca/2bc4f7697cb9f6897bf61aca11803df096a5d971bf69ef5538b243bb1fa8/orjson-3.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2715c4808d1571029ed18fd07a82140bf3ba7def0dc89f8d015c416e3649bf87", size = 134583, upload-time = "2026-10-07T14:09:02.047Z" }, + { url = "https://files.pythonhosted.org/packages/23/b3/12b1af9b87ff9fa0aaf4e5724c87672b30bb5de76f275f7fac64e8219c1b/orjson-3.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08bf722f923d2100bc5e5a5dcf72c656db557049c1bea26582fdd5dd9d5395a1", size = 126500, upload-time = "2026-10-07T14:09:03.863Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ea/cf257fc8a7f4b18f5677c22b3a9673a1b51d4b7161f25177ed389b76560e/orjson-3.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:6adcaa85d79977659a448b4123a88eb33511a11ed2db243535ad7ea88a6668e0", size = 121378, upload-time = "2026-10-07T14:09:05.375Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/9f4643f849e9918eab11983b83928af3aac14bedb04002e28e885ee1936f/orjson-3.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:83705c12b4afde10c62a5dd3fe6fdb21b7900bd0dcd5af1c85612ae94d0ee590", size = 126123, upload-time = "2026-10-07T14:09:07.085Z" }, + { url = "https://files.pythonhosted.org/packages/8c/15/d265f2b556c0c7c0b30ea830316d6e5af5b85dde08f234a1ebed60fab386/orjson-3.13.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5ef4d4157392a0439b74f7e49e5636b4ea43d9616bd0884effc0195fffcaa2d5", size = 223305, upload-time = "2026-10-07T14:09:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/0c/97/781be8b80a33b8171b3f5acea941af47182c8b4b5827c2b7c3fea706f21c/orjson-3.13.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:84d87e322e1674408f85adea63f11aa19201eba082755aec20ebc217f493bbd2", size = 123515, upload-time = "2026-10-07T14:09:10.792Z" }, + { url = "https://files.pythonhosted.org/packages/20/68/011bb98fa7da7b430b363db1bb7ef9160c438fc5c43e7468fb593c220037/orjson-3.13.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:8c2ac5c09b017c484df1b4c68b2cf250b4e8ba08204cb58e7cd6cbbc71a9c902", size = 129222, upload-time = "2026-10-07T14:09:12.542Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d96fa2aedaaec14c095ea9cd48d2158fdf33c0f4fd6e7a598d899d536b03/orjson-3.13.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:51d11525bc3ca736fa97ce4e4c7da9999cc00bf261522bede43b4e7531bd7965", size = 113152, upload-time = "2026-10-07T14:09:14.059Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/ee77aa685c54bd920a1f0e2936986b46269adb0d72bf5098c2c694dbeb36/orjson-3.13.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:ac81530647c3423107cf61c3481e91f57134e9ddfb6ef83f5150ccbdcbc3a3ee", size = 130749, upload-time = "2026-10-07T14:09:15.835Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/3411fbfdad61b3f3af22343b5af7ed5c8a1679e35f442e8f1b229b33040e/orjson-3.13.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:0526a3456db67b264c6d661b5f090077f326b6cd074d0ef53a72763595dec5d7", size = 130471, upload-time = "2026-10-07T14:09:17.463Z" }, + { url = "https://files.pythonhosted.org/packages/87/71/abdc2b8c70b8d85a6cb22f404da0f52d7d712f9d49cda039a0cb1adcb973/orjson-3.13.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dd61e64802d51d1e4f16531c64536354fc3bc67932dc0cff254044f72bf0f187", size = 134793, upload-time = "2026-10-07T14:09:19.084Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2e/1c13552d8b0241083116de02b2f284ee38501ef06ebfb79893f741538168/orjson-3.13.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c5e3ccaac3106e8fa6e2f2f6962449d7c757d7b067e41b395a19d6f0d6cec892", size = 126711, upload-time = "2026-10-07T14:09:20.645Z" }, + { url = "https://files.pythonhosted.org/packages/85/f8/d4ece953a519d064cf690adaa68cd389d5b64fd261726334841b32978d6a/orjson-3.13.0-cp315-cp315-win_amd64.whl", hash = "sha256:7804dd1d6161da0e53b284c2aebf20f23e78eaac617300803e1467d1828d987f", size = 121496, upload-time = "2026-10-07T14:09:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/70/cf/f691388c4a9bc4af7dcc1648c4b40845869908b517d7c0009d005c7d1fa1/orjson-3.13.0-cp315-cp315-win_arm64.whl", hash = "sha256:f5c05a8fee59309f537590a1ff12d3c1009c485e96a50a9ac60dd085c09d0fc0", size = 126260, upload-time = "2026-10-07T14:09:23.928Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathlib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/9b065a76b9af472437a0059f77e8f962fe350438b927cb80184c32f075eb/pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f", size = 49298, upload-time = "2014-09-03T15:41:57.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363, upload-time = "2022-05-04T13:37:20.585Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[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 = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/99/114c2e71405da7610c884c64f8370f85b2fbb69bd427158a3f714515a803/pydantic_core-2.50.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:55684bc850059fc85ff8dc21a3e342b722d3178993c49ebdfdee8b698a432720", size = 2152583, upload-time = "2026-10-08T14:26:12.913Z" }, + { url = "https://files.pythonhosted.org/packages/75/a7/fabcfea81a5c3525507af4347fa3a50e51036d5db6611a63e47379776e56/pydantic_core-2.50.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3ae59461625518449f800acc4f874753ae96fcf993c47a27be07beb651371fd", size = 2045652, upload-time = "2026-10-08T14:26:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/226019008a041da423dc59d34f980b2b6e60a71971d07a8f52e10fc4747a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7080928078ff56c07da393392f772054e93a39c8033dc9c04547bd949f9b614", size = 2050325, upload-time = "2026-10-08T14:26:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/b9/29/11c98cd9c49338f64aeb3d6e166c99b65d8949f00f285c5b66de75ade316/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b1bae96f41806b14dd8bb1568d39b5e33b5af7fb27f7be36c78b8fa84708917", size = 2189027, upload-time = "2026-10-08T14:26:17.581Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b7/af7d9c4de715cc13e947d1135b73f0c181bce2f01e9f1d9e3c428f9ee31c/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8facaf0a16121ac82cc403a71399a061b74624c1ae2034eec9ed6169c5d16ecb", size = 2388815, upload-time = "2026-10-08T14:26:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/cd95dcd4d066180b2e22eff3dca01ac3af56607f9ecc43b7ba0748496f47/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:610e9f483ac53c5cc59c2d3687224029b54eb494feec2d40a2bcfdd187635192", size = 2431821, upload-time = "2026-10-08T14:26:20.958Z" }, + { url = "https://files.pythonhosted.org/packages/f0/14/7122b78e9915e7dd50f9dcec6041a4e32874907b41b9e0880db2f1c7ae1a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac223ea031905319a64d0df116979ae438b42d6265862a3d63c9f9808c2905a7", size = 2157622, upload-time = "2026-10-08T14:26:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/97/86/fcddead5bda54fc8efa0d80dbba5962e9a91193570e227404c7593707833/pydantic_core-2.50.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:75b451ec5e64a1d4b803317f34710131742ff2e42f4bf5403f597d0af857d7d8", size = 2254921, upload-time = "2026-10-08T14:26:24.607Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e8/041d1c3e3e1f203a656ce592f8c1548e1ef2b0939d19c5ce66432c30e4bd/pydantic_core-2.50.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7eee44c3f1f8acc220a743a5ed5588949a4f18b33df4cfbef2b1277470285401", size = 2268163, upload-time = "2026-10-08T14:26:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/f515994ef1351e883276e85af0a50d839ead9d0d22743fa2c6dfd552f7ce/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f44107b5fdfecc03438feb165431652c8006650471326e53dd86d4c19124f5c5", size = 2302005, upload-time = "2026-10-08T14:26:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/b970c4c87d92c4c974a5c5dc16bcf747f89b4e5729c5c45131267ef0421b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:718d05d4e078c7828f40ad3e310870fe4b837d93a32e7e8497a080c1f1040490", size = 2465387, upload-time = "2026-10-08T14:26:29.529Z" }, + { url = "https://files.pythonhosted.org/packages/4a/84/c0a92f97f9de7f133dfe694d0e8ca033a1c90fdf692dc69dfee3bf8a2e7b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:031867348c98ab49c6d3a16ad39738369ea48da59900d76d71c1e64de71fe79b", size = 2478673, upload-time = "2026-10-08T14:26:31.898Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/88e851761351cfef90382f175b07cede48edafc62a1509575dbc879934ef/pydantic_core-2.50.0-cp311-cp311-win32.whl", hash = "sha256:e41f9d1d9240e8e0d8a670ad3e66c0c00f0b1f7150a31bc6c445a4f87c1cb3ba", size = 2121424, upload-time = "2026-10-08T14:26:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1f/89184f13b9ebaf6d8710cd0d1cfb466d7dc829de23468ab471b543dd07eb/pydantic_core-2.50.0-cp311-cp311-win_amd64.whl", hash = "sha256:90874428bc6678b26434336c77931c9734ecebedf50132c179205e5a9908761d", size = 2176278, upload-time = "2026-10-08T14:26:35.388Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7a/5a630fff51218a860aa4e7699f781a7c9bf41c031987b613629c98f16618/pydantic_core-2.50.0-cp311-cp311-win_arm64.whl", hash = "sha256:b9be297ffe1015bfb2db4a23b6e1fec7e48361cf7c7b4f4f6bbdd008451b0a7b", size = 2183464, upload-time = "2026-10-08T14:26:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/11/ff/8d126ca04417cbc76a81648c135296ecc2f36d07adfb0b3376638efd1dd0/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:38b03c5439c6a7a952f56e3e6596dae44bc78c3fb6a69df46cabfb2f888e5f0c", size = 2162541, upload-time = "2026-10-08T14:30:12.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/e3f11c40950d0f37b3891c1ed015f04a4ef36c881d04ef1a5d1ac91d1c6f/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:4faa766450ef44d9eabed5b65a280f63f903d1e1ed6d2e278961eb22345ec49f", size = 2075402, upload-time = "2026-10-08T14:30:14.968Z" }, + { url = "https://files.pythonhosted.org/packages/42/f2/727d2b23ff6ca67c9c050c4ff4e5abd7b599d9ebcd819a74141d3b11c7c1/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50920d66aaab60dbc6023c3035d55fbabfa58b6d205d0b39d47d820c4a7a13a8", size = 2108552, upload-time = "2026-10-08T14:30:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/4a/da/a4321058eea61f0504366668a09472a4c37514be30cce77967c35ab7ffaa/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586699b43066ca6038f96bd71d0eff0b6c292929e3d66ff63d5dc79d0f06d589", size = 2267745, upload-time = "2026-10-08T14:30:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/50/a6bd3c397b609aade207ffb78ce46f4ad4e29bb42a39007c87ee00e778f3/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:68421ba548f6d86c7dededd70bfe530b4faf67eb811c53c70a1be69438d3ce99", size = 2154371, upload-time = "2026-10-08T14:30:34.965Z" }, + { url = "https://files.pythonhosted.org/packages/11/3c/2d32a1f945b9809b65701d2b72315bdcd700f13c093df5e60baaa784bc6a/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:23e0940b7d73f405d92e98b2a05b93b16db163be13b7478bb19e1db2e486ade6", size = 2087028, upload-time = "2026-10-08T14:30:37.814Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/c2aea271192d8c4b81afeea9d61de8f5dc8686a1fcd1ace80e70a482fbce/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee9913fa2b5bfa6111c11158cf481bb13544f8bd5d10382fb6ee2612b16fcf28", size = 2259611, upload-time = "2026-10-08T14:30:40.581Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/32128d5498b95722fe914a21a25f59edd7dc8917474f47924005682532a8/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ad3532291deedfdfcad3cf5d351d0076d646c68a0c63869a1bebf87c22159600", size = 2268636, upload-time = "2026-10-08T14:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/7dae31279e07aaa63dd4b17756f4ebd5647aed6bee5581ee3b3e85d82a0c/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9279f4be22bc3765dd9f61f5b4d38d9cb219d167bbd6f2e0ffa3368b1b71b4d1", size = 2305416, upload-time = "2026-10-08T14:30:46.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/61e47defbd490618fcde2641f80d5c077f2961de62e88500edacb49297f9/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3b2f3e44af4c6512dd73557621385a71b18094c8be81d60e5a8e73a8ce96b7f4", size = 2466713, upload-time = "2026-10-08T14:30:49.214Z" }, + { url = "https://files.pythonhosted.org/packages/61/57/81327752c007834d684b0b7d20ce00087206f512c7347a6236fd89ef0223/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:593dd4f24abeb3ed90222f98bc6eefcac68cd5aa96fc7745f93862a802952bce", size = 2481971, upload-time = "2026-10-08T14:30:52.088Z" }, + { url = "https://files.pythonhosted.org/packages/48/c3/e321d3a4b2737372cf775ffdd1a02428c7d2791ee5afc9f228793a0ee245/pydantic_core-2.50.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b123f9d8702106f39dc3af63a031ca8b5279862a3747ec6c03ca49dbe78b71b9", size = 2337958, upload-time = "2026-10-08T14:30:55.045Z" }, +] + +[[package]] +name = "pydantic-function-models" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/d7/6afa9458be0e522b9332995cf15afacda5bdd081efe2965812d16b251cf4/pydantic_function_models-0.1.12.tar.gz", hash = "sha256:530964fe7d77a3e153a7394bd4a6b1bbd7aa4bdfc7bc1420e58ebf815a527175", size = 5424, upload-time = "2026-04-16T08:35:10.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/ca/cc63a2ad7ddd490f77c59e37e2e88d8ad2a39bdaa8588101082b94359333/pydantic_function_models-0.1.12-py3-none-any.whl", hash = "sha256:ef009e84000870770579895c490be7397f900112ba7a36bed5d0e5b98ea4f108", size = 6969, upload-time = "2026-04-16T08:35:08.974Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[[package]] +name = "pysignalr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "msgpack" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a6/ac80bd4972604c6930050e8b0eba41d6fde41cb3286087be0188a8865f55/pysignalr-1.3.0.tar.gz", hash = "sha256:ca2e4372f213d82148fa2f060f0fefd096f1f66fca1107ac05e76ec6abd4cf52", size = 16790, upload-time = "2025-04-29T21:23:38.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/54/74e35563927ab8538e1bd1404b35027861434aecf2bcc945ff30c188e56d/pysignalr-1.3.0-py3-none-any.whl", hash = "sha256:423c91b0d1dc8387f37118ac2d1dc87ed6b9e01993a04612eab8452193f40344", size = 19966, upload-time = "2025-04-29T21:23:37.513Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/65/f8bae11b228647e2e2f45b63dec7448efaddb7cb51f529de1fdba69e63b5/python_engineio-4.14.0.tar.gz", hash = "sha256:eaa1e386baf9c2c7959eef7f9d9165c5ea910c5b392f5316e78d29ed073cb43d", size = 80863, upload-time = "2026-08-30T19:52:01.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/de/07cfd386974c2a26a7bde41f2111be29bbfc92b9ea0bb76694415a4a1a78/python_engineio-4.14.0-py3-none-any.whl", hash = "sha256:9f0fe275fb7d67bfc1a632421adf22949fd4843bd9c458c004b0a89cede302a2", size = 60291, upload-time = "2026-08-30T19:51:59.776Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/04/8647675c93b5e74a3daa41a2a03930bac0cbdcfcf307900f0441ae6550ba/python_socketio-5.17.0.tar.gz", hash = "sha256:c3bbfc4937dcfea7c4d1b182afa94d4a30335d153987e8f2078b344beacf95a0", size = 134574, upload-time = "2026-09-14T22:51:02.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/be/44b558c944bc16618483967ecd3424c578705aa33ceee7df8c1e4ab43ea0/python_socketio-5.17.0-py3-none-any.whl", hash = "sha256:b5826fd2f8aa02e11347816349b74ac6b53e8a4f4e4b1cf1388e1aff19b7f3f4", size = 82548, upload-time = "2026-09-14T22:51:01.405Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[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 = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, +] + +[[package]] +name = "tenacity" +version = "9.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/9e/497c1c8ebe5a5b5d1d4a7511aea22c0bb1a97e3170d98abdef0e1b34265a/tenacity-9.2.1.tar.gz", hash = "sha256:a606b5c808d0cded4a359d5b9932d867ff2a6a6b64d37350260fd01bbdf83839", size = 58261, upload-time = "2026-10-07T12:13:01.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/26/1ff2b0721ac66a3ec5b1402b333110b352ab0a8724052ac279a7b82d40c4/tenacity-9.2.1-py3-none-any.whl", hash = "sha256:9e56f17539296baab7beabb08b92f6ee3d7be92d8be72d763360677c2ad6580e", size = 32310, upload-time = "2026-10-07T12:13:00.102Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uipath" +version = "2.14.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "applicationinsights" }, + { name = "click" }, + { name = "coverage" }, + { name = "graphtty" }, + { name = "httpx" }, + { name = "mermaid-builder" }, + { name = "mockito" }, + { name = "pathlib" }, + { name = "pydantic-function-models" }, + { name = "pyjwt" }, + { name = "pysignalr" }, + { name = "python-dotenv" }, + { name = "python-socketio" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "truststore" }, + { name = "uipath-core" }, + { name = "uipath-ipc" }, + { name = "uipath-platform" }, + { name = "uipath-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/ed/64d58303c35ca2dd01a04f38f213bd45acee26cf48501ed5e0a5ebed1a23/uipath-2.14.43.tar.gz", hash = "sha256:0e2c21f0e620f913f5ae8729ca95a7546de44fa38ab5c2178a58bb083a930939", size = 4599092, upload-time = "2026-10-21T12:49:58.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/58/1c4bea4860fcd036fff546dce1e867f403ad255af4639070730465ac57e5/uipath-2.14.43-py3-none-any.whl", hash = "sha256:ffe9bffe66638570190325fd4e5a575a3f2f3818305049f1d01285936ffe8a36", size = 456271, upload-time = "2026-10-21T12:49:55.777Z" }, +] + +[[package]] +name = "uipath-codex-sdk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "aiosqlite" }, + { name = "httpx" }, + { name = "openai-codex" }, + { name = "uipath" }, + { name = "uipath-llm-client", extra = ["openai"] }, + { name = "uipath-platform" }, + { name = "uipath-runtime" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "aiosqlite", specifier = ">=0.19.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "openai-codex", specifier = ">=0.161.0,<0.162.0" }, + { name = "uipath", specifier = ">=2.14.41,<2.15.0" }, + { name = "uipath-llm-client", extras = ["openai"], specifier = ">=1.17.1,<1.19.0" }, + { name = "uipath-platform", specifier = ">=0.2.34" }, + { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "ruff", specifier = ">=0.9.4" }, +] + +[[package]] +name = "uipath-core" +version = "0.5.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/32/8a254ae086cfa0ca9808d36b9084cffeea151b4250e77e84eed8c9dcde17/uipath_core-0.5.33.tar.gz", hash = "sha256:f533a3145131ce93bde78a264f98a796134dcd7a6ebf3211d8247a49d3b3eebc", size = 131308, upload-time = "2026-10-06T19:39:30.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/15/3aa01f0011515c0508e966eabf6b0949d61d8c2c326cf83f943616733ec3/uipath_core-0.5.33-py3-none-any.whl", hash = "sha256:13c541c9b684e3f7556c830442b9fa48a3a42a36a38a3086195b10eeb4360ffb", size = 55133, upload-time = "2026-10-06T19:39:28.9Z" }, +] + +[[package]] +name = "uipath-ipc" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/ee/182b16ff0cd205cb172def92349e7625e36d5b2ba11bdcb98bee43ccf548/uipath_ipc-2.5.3.tar.gz", hash = "sha256:823cdd1192c72ca786bfd3cbff65f88e2b02c33f450046f13e572022b7e6b870", size = 86363, upload-time = "2026-09-15T15:32:58.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/33/eebf6d9b5021d0517d5b7ca0f27bac48dda68530a2322bc04fd3c636e737/uipath_ipc-2.5.3-py3-none-any.whl", hash = "sha256:139852a85c8235f7f98959053a97ed748e9a051ddef952b99e49b6097c36d7ad", size = 50342, upload-time = "2026-09-15T15:32:56.962Z" }, +] + +[[package]] +name = "uipath-llm-client" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "tenacity" }, + { name = "uipath-platform" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/0a/d5428819553e386ae5c72fba61ada08c6218949905574892127823e3c7f4/uipath_llm_client-1.18.5.tar.gz", hash = "sha256:10525d1c97a937af117b41f4164e83747d2de83e9b11ee523ff0ba62c07daf0a", size = 12534362, upload-time = "2026-09-09T10:48:49.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d0/68d0b33b18181e9986430d2c16dfb6898e98a79113e3bcc8e6a46dbb9f3c/uipath_llm_client-1.18.5-py3-none-any.whl", hash = "sha256:f9b12229ef7e104708f95b4faa19da5ab439a0b50ccfff2c6e658a999f61858c", size = 71052, upload-time = "2026-09-09T10:48:47.653Z" }, +] + +[package.optional-dependencies] +openai = [ + { name = "openai" }, +] + +[[package]] +name = "uipath-platform" +version = "0.2.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "pydantic-function-models" }, + { name = "sqlparse" }, + { name = "tenacity" }, + { name = "truststore" }, + { name = "uipath-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/bf/6427d99e3fcb85420db8d44ab65f3e35f5e3941c060f09c7088018d3ca2b/uipath_platform-0.2.41.tar.gz", hash = "sha256:b3082aa892f3922eb2aa756135554569dc1d43f8aacfd2034733d1469ac5aea4", size = 472238, upload-time = "2026-10-21T13:43:31.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/42/9fff8ad222319492733c39b49f382bcfc9a13e0ddc76b20d536aa40f9861/uipath_platform-0.2.41-py3-none-any.whl", hash = "sha256:1121bf80228bb7bf0abf27b37b8454857e11a517f451775bc8ddca07da40967e", size = 304378, upload-time = "2026-10-21T13:43:29.651Z" }, +] + +[[package]] +name = "uipath-runtime" +version = "0.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "uipath-core" }, + { name = "vadersentiment" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/8f/14ff9ccf152b6d97c0881f6b40d3e01037045ee52c467ed00dbb2319a0cb/uipath_runtime-0.13.5.tar.gz", hash = "sha256:923e4514f3d868f3711211fee6e2dd39f76c6aaf7354fec9e80cdc5e6fad5d80", size = 250415, upload-time = "2026-09-10T09:32:53.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/88/b851e75b3c70bbedde1e24d5b5f38035863ed3d50e8facb75ac7d2db40b8/uipath_runtime-0.13.5-py3-none-any.whl", hash = "sha256:d5eb7d578689f67d5dd631d28a092954c9a8797e130e16fe46e2895e4b0656ac", size = 98513, upload-time = "2026-09-10T09:32:51.404Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, +] + +[[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/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/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wrapt" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/d0/7c23187af053bfa54c99964f87b94b96cea76b78f019b06e42fa86752e5d/wrapt-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:57fa1a3fd1279b3ca7655b943ad61d298f2a2464a4cdca7ff298058e408322f9", size = 105327, upload-time = "2026-09-27T01:39:46.242Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/6f7f880207b2d74162a44b35e41217d999fd4af48338afb356c650f89a07/wrapt-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e58f96849f622ce769dcd705f83c7445cd9829bce1dae00e78bb031aec8096", size = 105501, upload-time = "2026-09-27T01:39:47.592Z" }, + { url = "https://files.pythonhosted.org/packages/6f/19/b0b7e7cf1a5499a25bd5260b38deb3b17aa9ca7b268ef29e546dcb683aa7/wrapt-2.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fd91203e156d610ecb28b9ccd7b764af7a7b38662d7c163090babab0d10def0c", size = 239849, upload-time = "2026-09-27T01:39:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/7d/cd/b6cce206889497626f9b8b9d2ecb74324287d853bcca13c8bfab9b99a981/wrapt-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff909b934b1958e31784d412abab5cbb0709fdbc01c86f22965e1d15331371ba", size = 241355, upload-time = "2026-09-27T01:39:50.368Z" }, + { url = "https://files.pythonhosted.org/packages/70/6b/11b3b25915bfed1b47b7486a306602f3c3698b5a6b73b05bbd59bd891650/wrapt-2.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:148052fc55013930217f531c6978e918ab210a12bd73cc9bd6de661a7adaf620", size = 226349, upload-time = "2026-09-27T01:39:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/f6/97/9901b407cacfd7b3efca60f808713294e5758dda023586fe201fe203d134/wrapt-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d4885c5625c9d2dcb49458700851574e9d0ea046c7a265526e990282f8ae8e", size = 239003, upload-time = "2026-09-27T01:39:53.01Z" }, + { url = "https://files.pythonhosted.org/packages/5c/68/b7883fe1c6b445df44e32a2a3a19a544a3a73749c5064a539c10f9f730a4/wrapt-2.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:672dd1bab4256311db1520b1b50e7a10cbaeaae2b0ac6bc5d858cc387ee605a2", size = 223603, upload-time = "2026-09-27T01:39:54.606Z" }, + { url = "https://files.pythonhosted.org/packages/f7/05/f9d527f0da33b901684ad326b6b9fc07593013f9594b9db4ebb0ddc1e127/wrapt-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c3a78a3161b3a9bf07725822fd24d379c1f3f161b6db49166c4131096ea73b4c", size = 229164, upload-time = "2026-09-27T01:39:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e0/4a05ad93a003272619cd98dbfbfb7b45a9c7d1041215473d789d9c6ce8ed/wrapt-2.5.0-cp311-cp311-win32.whl", hash = "sha256:0810e060e58f7960405172ad21080df8e7335841c9fe97417bd7d3f05af24f90", size = 100422, upload-time = "2026-09-27T01:39:57.216Z" }, + { url = "https://files.pythonhosted.org/packages/1e/96/5dcc944f39724e5df8a51f9095e856463df231764d503a7a935503eebeb9/wrapt-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:99f8ea48f14a71c5e2df8763e9a490e8af63096dcd67755b7bab0a4b74fc7cd7", size = 105616, upload-time = "2026-09-27T01:39:58.43Z" }, + { url = "https://files.pythonhosted.org/packages/a3/91/c927dac776c8939616ce865efa4bf3e62c933393528487e8fde981aad356/wrapt-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2ac82ef59ee05e259902bc7cf73dee5e6397845e8ccdc376d9d25536b59a877c", size = 102562, upload-time = "2026-09-27T01:39:59.672Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4b/cc7bb5668f7ddc0e73e236e96a0c06cab8fddfca9c53538c9dffac62db6f/wrapt-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b312b3cc87951faaed3cfef984d768ee8bee7f935d9cc929aaa9946b0b96a98c", size = 105808, upload-time = "2026-09-27T01:40:15.826Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/5d15ef0e2f42d5f084930dc4863e6e52c160c28301c8780aae170c58421c/wrapt-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c57ddae24cf72eb6bd18112638a987cafe6109d90f2df111e6934362cc03ac1a", size = 105931, upload-time = "2026-09-27T01:40:17.136Z" }, + { url = "https://files.pythonhosted.org/packages/1e/02/c7174e78b0c38bb279b2d3c25a6bd7fb9d3b0200c3e5a8fad084e7cc3e85/wrapt-2.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b95a6eca3b927853529eea958310563c83140ae8451dd5dc4399c7da385dc4f3", size = 248259, upload-time = "2026-09-27T01:40:18.446Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f9/47ae1d7ef325c3f6c81ae3c1fb4a3fef9d98c8025ed676c0bfc1550903ce/wrapt-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6058e12e9caa33468f9a36fb88c15a4bb30a479f997b37834b83abdbf062f264", size = 248224, upload-time = "2026-09-27T01:40:19.713Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/a394448bcbbaf8e5a3f856520edbbb1b92fc42061def56284c9083f3ac87/wrapt-2.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d245ac03f5ae77f1eea6eb19edd9e778c2f772490c20496c2f1cd3a102ee1b6", size = 227137, upload-time = "2026-09-27T01:40:21.359Z" }, + { url = "https://files.pythonhosted.org/packages/41/45/fc252bda5aa1ca01bc838d3b108778e786a2a13d0c52fd17c5f6179aa246/wrapt-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4ef4935962f7029b2058a99f1a47ccbffc3be919dddb3becb6c2c48eac3d9f0", size = 244710, upload-time = "2026-09-27T01:40:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/527d1bde7371dcc2c378d88c97de03b121b486fb4cd3dbb399bc332c0676/wrapt-2.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f12e80c3089ebc03727d368f8205b811b5af2cd4a72b5e4cac75e901dd316e39", size = 225053, upload-time = "2026-09-27T01:40:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/2b823fded8c3b815408c58633929812eacd29d824fe57548b7868c4ee422/wrapt-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a346408f19b6d589bf029f25f65c0b4cdeed6302ef8f40da4e5d1552d22dc037", size = 236884, upload-time = "2026-09-27T01:40:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/00/d7/5d185c1193b073a0bf4cbe862b5d31f81067eddc39eff30ae632f346563d/wrapt-2.5.0-cp313-cp313-win32.whl", hash = "sha256:79e68f0fd7d381b9bbd71776f602a2d5440d4d2077459128e02fd6607465422c", size = 100198, upload-time = "2026-09-27T01:40:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9a/51d95640e01d0ebdd04a7223755f076e4936b0c124ce99bb01a12b53e66c/wrapt-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:77f0a74ff6f6cf89f5b673a732d5afe1911a6e6b1c017260836fdfdf85518dc1", size = 105610, upload-time = "2026-09-27T01:40:28.465Z" }, + { url = "https://files.pythonhosted.org/packages/67/52/183d5ce7c2a9391774e6a623be6ae564351545713ec9a3693528f89c8e85/wrapt-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:b620d7559b6b2197c5730332fab0867ecf1c8cb74d45533ebbcbcad1eacf4616", size = 102641, upload-time = "2026-09-27T01:40:30Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/0009086ab8f2d5fb32405ef49fdd11104ce40f69ae9f4cdfba8326462816/wrapt-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65f2ee406dc592a5b22a7dc6abac13e8a3e8de4b2ecf5dc3c22937865496e4b6", size = 106182, upload-time = "2026-09-27T01:40:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/8f38339a4c55a42df00296dcf6ad50598d2280049f7a6ffa525e9a1f66d1/wrapt-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d75d6366203c8d025c1a74bae0b565952187ddae79bd5c7bf10687652a56f020", size = 106123, upload-time = "2026-09-27T01:40:32.927Z" }, + { url = "https://files.pythonhosted.org/packages/23/38/285b433121d73c7a447b5b82d93c91dc3330f33ab5853975f34551e0c773/wrapt-2.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b640460f0ffb346b192686bd6fac5589e35a6c6640501c59a9fb6e82b0dd6bd8", size = 251245, upload-time = "2026-09-27T01:40:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/3f63f4637e89484c1839a9ba3aedda5b2912e7ce12617034c6bd49752cfa/wrapt-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f17c5a3836397bf59fd57b0e5b0dc42969b1daa70d31aa361c6e13cbf138b5a", size = 250483, upload-time = "2026-09-27T01:40:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cd/f24ee96016da222dbb921cfb22e2beb5ca189a7b730ef49e8bb106b49449/wrapt-2.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4343880acd72e74233baf092285aaaf4306244e31d7601828bd2600316027df0", size = 231341, upload-time = "2026-09-27T01:40:37.295Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/c9b180124271e494f615a130f966be56143e3e26e87706bc28582f94bc09/wrapt-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fdc79c0045d6bb1603c109127145245cafe888588888444e1e37fbeadbac3", size = 247736, upload-time = "2026-09-27T01:40:38.653Z" }, + { url = "https://files.pythonhosted.org/packages/fd/60/345b8c213389809435d1950136b09991a1af2a66b988d0cd930ecd1b9f19/wrapt-2.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42239c89430eee2d8a6dec39e34677abdbb67fff63caf2467dd6124ea4d4d58", size = 228533, upload-time = "2026-09-27T01:40:40.12Z" }, + { url = "https://files.pythonhosted.org/packages/ea/15/c79f0f5827a9062c6be4fc25dc73e92fe1c014c7bfde2e61c8c0b56a91af/wrapt-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bad63bb4dea3c58e8078a3a173259ac2df5442a437e49c632b4099c0250e803b", size = 238830, upload-time = "2026-09-27T01:40:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/5f/de/79a95ac238c9cae7ae7eb3a18501afc646e3ed61d8d108c725b17bbee301/wrapt-2.5.0-cp314-cp314-win32.whl", hash = "sha256:b58138d19f34e32833e62de5e910bc2a8baae43310b921d783bd39b15227c2dd", size = 100664, upload-time = "2026-09-27T01:40:42.884Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/32de0f1e6a46a82c773430672562d53203406df14a6d73c93abb59b679e9/wrapt-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3c4035d2026b87ef23dd8d165f1f8d3853ee2bd02791cfd22bd8c6226c41ce", size = 105986, upload-time = "2026-09-27T01:40:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/10a7ff69097385de15b3db7d91587a54c26f8025fcbf36a1d9e83a1e0ad1/wrapt-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:def66258d97ebf1e4e97def12c5daa542d1cc728a3da83ed3a43933f56df6dab", size = 103718, upload-time = "2026-09-27T01:40:45.956Z" }, + { url = "https://files.pythonhosted.org/packages/f7/01/963f893b1906ac6c2aecb777c36e9ab2156a4cf89fabf6125e953ec4ad52/wrapt-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:23a9d6cb6413359b76f030d6bbb75340b7669c69da245dde2919a4c93708993b", size = 109290, upload-time = "2026-09-27T01:40:47.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6c/30e04d2b1284de2eea5411850008e0411d1876bf4552dc0990c904a0a783/wrapt-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:21cfe343ef9c2deb865ad0d5c57822266447c88dcf6d8805dd8c363fe367f30c", size = 109597, upload-time = "2026-09-27T01:40:49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/3980fe5a899b69454f66db2991c144ecc828dbbd355ce6cd7b881056ebbc/wrapt-2.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dfc38cb672af51fc29696ba9c6f05d2315f5e62c4af2564e50f07f81198a163", size = 283840, upload-time = "2026-09-27T01:40:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/b37001235fd5871b3f31941229f8fef608279353b772dab3ccb248fd8726/wrapt-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181a45000506a6382eb337354ca7e8525690702f1ccf2eae4f23a210ff339543", size = 293622, upload-time = "2026-09-27T01:40:51.868Z" }, + { url = "https://files.pythonhosted.org/packages/09/b3/9b751c6268fa2111efc7e43895105bc0f60a83896b08581009e77563f8c7/wrapt-2.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58b2a87c65cbfb20917ec48ace47f71b1962c1f81dbf18a4052e3037abf72028", size = 262840, upload-time = "2026-09-27T01:40:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/b7b51d601981ccc1f7b9e6023991548dec43dc9d40317597fbe0085bc876/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea62bc142f4fa8b2e0ab058f50699ccd679ef6199c8fa3cc1c2396c7a659000", size = 288781, upload-time = "2026-09-27T01:40:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/1a84f288246905d0a71d44aa1f470ff8c75df2b96ef791d2938124449cb0/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:425349a99b8c9540399d36620c376dc26e6aca93071cd6fafa239c2f1b5d53a4", size = 260116, upload-time = "2026-09-27T01:40:56.667Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/0572224d1c4a3f0846f82614702ec3110d45c843dcc7781c5f33779e1fdc/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe09aac4837ec720af606a493e814dcc3f65631984e1b7231b589efcf9917024", size = 278485, upload-time = "2026-09-27T01:40:58.443Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/5a5c111f8ac6dd15f54437c2161588431d3924718a7e4de59c471cd794e9/wrapt-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:bc5607c1911c92530cb402ea90d818933cffb28bd8de9b453a2542279816d8c7", size = 103157, upload-time = "2026-09-27T01:40:59.995Z" }, + { url = "https://files.pythonhosted.org/packages/e6/80/96cc2da58cbc0893f5165f6a0f4f9cb75d7574f409022ad792aa80a0ff3f/wrapt-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7138b0e7990e5555a905c519e8dad17c1da1f20e08b283e202c414229065740f", size = 109683, upload-time = "2026-09-27T01:41:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/c7/70/10dab499970e66c926ba6d404ff456318b68092b8ad0c4608a52160e43a2/wrapt-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5bb346a34499e091e4fa23df58251ad192173c088e5413d893ca1c730c133c7", size = 105953, upload-time = "2026-09-27T01:41:03.041Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/5154954f69afdbf5bdeddc07ed60f30bf6e83ed1e9fb6f96c66cc20e4223/wrapt-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a45a5249a6965d91aac9f991fda7c17e6b8b41fe91592a6099f182bf53c82724", size = 106186, upload-time = "2026-09-27T01:41:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/7db9952d26b1a89afcf22da8ec7948e6ca55c48721488d53f84754eed89d/wrapt-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cbd45dfba6b5c1bfbabe1feb3c0f117fbd62416e98268d9a7cd9ad8802875356", size = 106155, upload-time = "2026-09-27T01:41:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/41a0d7f9cf1f8e6aaecbf4b5b4eaacf4036fa3396c7d814364e07728a041/wrapt-2.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc6491d3008ecabf685b0746f03ad8241a0950336939b14addb03af39b51a316", size = 251754, upload-time = "2026-09-27T01:41:07.26Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/dd2de1260a490cd55d083b3c1bc47a36aff0e8363249d108d3b34c091c0e/wrapt-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47abb2bb7f15b416e72fbe5e68e49a6f09331dae6af1ca6055f5aa2251d2bd2f", size = 250768, upload-time = "2026-09-27T01:41:08.681Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/d08297feb5728cd6d3c1133633cad0249c2b7a82eb0213062ebab9cc1266/wrapt-2.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e25e9697f60af41fb86b08697e470b1e7eb6cd6ac0eb25e4b1f519839adc271", size = 232618, upload-time = "2026-09-27T01:41:10.293Z" }, + { url = "https://files.pythonhosted.org/packages/54/52/d8ca61b26c2a34927cc999fc250f1018f415741691581280e6a76cced736/wrapt-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bcd42e7b69c8c1e33a29b79b28de03bdc08876a49745830f9162a3af860e06d0", size = 248043, upload-time = "2026-09-27T01:41:12.132Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5e/ba02904736e2d3b05afd7447b4ddff677ce61762265939555541adb8c668/wrapt-2.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:36703cafc2ec059e118c2175e6cb7ad7299c2924aecdcb1b7a7ebbf7a3e20c19", size = 229577, upload-time = "2026-09-27T01:41:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/a0/94/23968c18a6e37a8a130706dc52ccf4344a71f1fe53c99965eb0a7715a459/wrapt-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc0d09906057ada57a32158a657364ca40b8f86e604da3e7d979069b601502", size = 239372, upload-time = "2026-09-27T01:41:15.459Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ee/8437e73fffa57c96a5f0f6721f942ccf7b1461b64cd965f83e2181e25252/wrapt-2.5.0-cp315-cp315-win32.whl", hash = "sha256:76fb341d5a707a4f211631b8c77259b2df149147e9d9c245ae6ba3dd936bfdfb", size = 100673, upload-time = "2026-09-27T01:41:16.925Z" }, + { url = "https://files.pythonhosted.org/packages/37/6d/6d640f98197d68e61fbeaded20478e4aa840f9c88d11e7a49c8d6d14ae15/wrapt-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:6269637d9a54990430b4a769df15833935a46c4d004d9fe8a153bbadf0b9a097", size = 105999, upload-time = "2026-09-27T01:41:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3e/8b8a0c94f2698c99afb499510824b107c81e9c4a34b3db7e877233a634b7/wrapt-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:1122f4f9e363da804ccba05a9f0f39baa3716eb82452c929258bc3f1420c899b", size = 103728, upload-time = "2026-09-27T01:41:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/37/96/88f08f58759ee3739544cc51941853e946df1f400ba60c6efbeccfb589d7/wrapt-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e3c6fb1c1a516881353186bed9cfcb8899f968c03b3509720c79db0d967acf3b", size = 109275, upload-time = "2026-09-27T01:41:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/29/cc/68846aa92814d0704d4b128a30d7707368be6951e8a8f42f1254ce4ab31c/wrapt-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0a7a369e7fca9fc8c2c50df634382009b09af416853da3d4515e4bb048a5b9ee", size = 109610, upload-time = "2026-09-27T01:41:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/bbaa188dace348b6a403bbf3cc483f3f419ac97700274340e17f2dbc700e/wrapt-2.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88bb24b9fdccb1d805258d5648533206eb58c58b6554985c47db53a89c11be85", size = 284602, upload-time = "2026-09-27T01:41:24.094Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/8a3309b0cbd3ab809ee6b76812c3be211f5a08732f321f131d26bb4f078a/wrapt-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e3d110d7f99644946f249927c346d9dba507a78815d1bcebdbf0d94c14c5649", size = 294893, upload-time = "2026-09-27T01:41:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/15/b7/eda8bbdb6a3b7343d2c71e23fb0ebfc15c12fd470e3cce7ea42f7a57aaac/wrapt-2.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ffb2823c95dbeb8a47fedfba9636b2afeb0a8ef94b66df97bd081bdfe5a263f", size = 264658, upload-time = "2026-09-27T01:41:27.375Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/589bad71ca3ce5444a626ee10d657fd6aa5080ada76b3bcd4550468aa16e/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:557ebf4ce5568588368675014a2540405db687c2e4c7ad1eb83aa7e857be1864", size = 289974, upload-time = "2026-09-27T01:41:29.032Z" }, + { url = "https://files.pythonhosted.org/packages/dc/97/c48f3c820ae6687e87b537041cd49ad4caa41e05a8f0d8ec08e449ca303c/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:05246a100da68259af521b88788f131ba005465f1c95d358cc3c03ec5e351b52", size = 261945, upload-time = "2026-09-27T01:41:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ad/d96898f500cb1e4185474bac6cb14bb7ea670a32f37e8c354a0c647e3a92/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c932273bc43b068538f3874fa5e6c2a60f33fa0b11c1ebc7768652f6a0608943", size = 279744, upload-time = "2026-09-27T01:41:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/7981d2ac838d0dc07e81145cb1c9911812e060b98e8c5a47c84fb92f8f81/wrapt-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:2fd8a61c31220840c7f52621cf51c961af5058bd009a55bcdf4a6732bdb13b35", size = 103124, upload-time = "2026-09-27T01:41:34.117Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1d/374cec175b6087e1067a780374d81d74ca966e1e5e39e666402eaee19a65/wrapt-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1d4da5f0e9a719471502b0db80d5c97503aeca796b7aeb9ab8f47403b2be76e6", size = 109671, upload-time = "2026-09-27T01:41:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/fc9e477a1771bec52d7677eee5e8404afe662a47efe1859405a18fff206c/wrapt-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78b7bdaa8b27b7f7607c66bdb6ab15c1dcbd9e9a1556a253a347dad511f615d1", size = 105969, upload-time = "2026-09-27T01:41:36.973Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] From d0ff8277e973d70f8cf24fcd7a74d35b6482dbbf Mon Sep 17 00:00:00 2001 From: Alexandru Groza Date: Thu, 22 Oct 2026 11:49:00 +0000 Subject: [PATCH 2/2] feat(codex-sdk): conversational agents A Codex agent becomes conversational with uipath.json runtimeOptions.isConversational (or when the platform starts it for a conversation), as in the Claude SDK integration. - One chat exchange per run. The conversation is one Codex thread whose rollout is kept in the state database under the conversation id and resumed for every new message; each exchange ends SUSPENDED with no triggers, so the chat service gets endExchange. - Assistant text streams as chat message deltas (commentary included); UiPath and MCP tool calls and shell commands render as tool cards. - A durable tool inside an exchange keeps the exchange open across the job suspension; a new message while a call is pending re-suspends on it. - Files sent in chat become job attachments (Analyze Files and the other attachment tools can read them). - One "Conversational agent run - " span per exchange, with isConversational, conversationId, exchangeId and threadId. - samples/chat-agent; uipath-dev in the dev group. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-codex-sdk/README.md | 99 +- packages/uipath-codex-sdk/pyproject.toml | 1 + .../samples/chat-agent/.env.example | 5 + .../samples/chat-agent/README.md | 126 ++ .../samples/chat-agent/codex.json | 5 + .../samples/chat-agent/input.json | 15 + .../samples/chat-agent/main.py | 58 + .../samples/chat-agent/next_message.json | 15 + .../samples/chat-agent/pyproject.toml | 14 + .../samples/chat-agent/uipath.json | 5 + .../src/uipath_codex_sdk/agent.py | 5 +- .../src/uipath_codex_sdk/runtime/__init__.py | 2 + .../uipath_codex_sdk/runtime/chat_events.py | 563 +++++++ .../uipath_codex_sdk/runtime/chat_runtime.py | 440 ++++++ .../src/uipath_codex_sdk/runtime/factory.py | 178 ++- .../src/uipath_codex_sdk/runtime/runtime.py | 121 +- .../src/uipath_codex_sdk/runtime/schema.py | 64 +- .../uipath_codex_sdk/runtime/session_store.py | 18 +- .../src/uipath_codex_sdk/runtime/telemetry.py | 27 +- .../tests/contract/mock_responses.py | 69 +- .../tests/durable/test_chat_e2e.py | 858 +++++++++++ packages/uipath-codex-sdk/tests/test_chat.py | 1306 +++++++++++++++++ packages/uipath-codex-sdk/uv.lock | 871 ++++++++++- 23 files changed, 4808 insertions(+), 57 deletions(-) create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/.env.example create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/README.md create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/codex.json create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/input.json create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/main.py create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/next_message.json create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/pyproject.toml create mode 100644 packages/uipath-codex-sdk/samples/chat-agent/uipath.json create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_events.py create mode 100644 packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_runtime.py create mode 100644 packages/uipath-codex-sdk/tests/durable/test_chat_e2e.py create mode 100644 packages/uipath-codex-sdk/tests/test_chat.py diff --git a/packages/uipath-codex-sdk/README.md b/packages/uipath-codex-sdk/README.md index c39853f0..b753080f 100644 --- a/packages/uipath-codex-sdk/README.md +++ b/packages/uipath-codex-sdk/README.md @@ -194,8 +194,8 @@ agent = UiPathCodexAgent( * State lives in `__uipath/state.db`, including the Codex conversation. An agent with durable tools runs on a run-scoped `CODEX_HOME` (`__uipath/codex_home`, so `~/.codex` is not read) and with codex's ChatGPT - backend calls turned off (`hermetic`). Agents without durable tools are not - affected. + backend calls turned off (`hermetic`), as a conversational agent does. + Other agents without durable tools are not affected. * The child job's `ParentSpanId` is the tool span. The job attachments in the arguments' attachment fields are linked to the child job (see "Job attachments"); BTS correlation (`parent_operation_id`) is not passed. @@ -828,8 +828,90 @@ agent = UiPathCodexAgent( * Batch Transform on an attachment: see "Batch Transform on a job attachment" under "Context grounding tools". -Not supported yet: conversational attachments, attachments in Integration Service file -uploads, and TIFF files in Analyze Files. +* Files uploaded in a chat: see "Conversational agents" below. + +Not supported yet: attachments in Integration Service file uploads, and TIFF +files in Analyze Files. + +## Conversational agents + +A Codex agent becomes a chat agent with one flag in `uipath.json`; the +agent code does not change: + +```json +{ "runtimeOptions": { "isConversational": true } } +``` + +`uipath pack` copies the flag into the package, which makes Orchestrator +and the Agents / Autopilot chat treat the process as a conversational agent. +At runtime a chat host's conversation id switches it on too. + +```python +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + base_instructions="You help colleagues pick a meeting room. ...", + tools=[FindRoomsTool(), uipath_analyze_files_tool()], +) +``` + +* Input and output: the UiPath conversation message format + (`{"messages": [{"role": "user", "contentParts": [...]}]}`) on both sides. + `base_instructions` is the system prompt. `prompt`, `input_schema` and + `output_schema` do not apply and are refused (`CONFIG_INVALID`). +* One exchange per run: the runtime takes the newest user message, runs one + Codex turn with it and ends **suspended with no trigger**. The job stays + alive for the next message, and the chat host closes the exchange + (`endExchange`). +* Memory: the conversation is one Codex thread. Every exchange resumes it, + so only the newest message is sent and the earlier ones come from the + thread; it does not matter whether the host replays the history. The + thread's rollout is saved in `state.db` after every exchange and restored + before the next one, so a new job (another robot) continues the same + conversation. The state is keyed by the conversation id (the runtime id + of a chat job). Codex's own auto-compaction keeps a long thread's context + bounded; a rollout over 8 MiB compressed logs a warning. +* Streaming: assistant text streams as it arrives (one `chunk` per text + delta, `text/markdown`). Commentary (Codex's progress notes) is shown + too. MCP tool calls (UiPath tools and your own servers) and shell commands + (`shell`) are tool cards on the message that called them: + `startToolCall`, then `endToolCall` with the result. Reasoning, file edits + and web searches stay state events (debugger, traces). +* Durable tools and escalations work inside an exchange: the call's card + stays open, the job suspends on the call's trigger and the exchange stays + open (no `endExchange`). The resumed job ends the card with the result, + streams the rest of the reply and then ends the exchange. A user message + that arrives while a call is still pending is not processed: the exchange + suspends again on the same call(s), with their triggers. +* Files uploaded in the chat (`urn:uipath:cas:file:orchestrator:`) + become job attachments the run knows, listed in the prompt as for any + input attachment, so `uipath_analyze_files_tool()` and the other + attachment tools can read them in this exchange and the later ones. A + message with only a file is fine. +* Tracing: one `Conversational agent run - ` span per exchange + (as low-code names it), with `isConversational`, `conversationId`, + `exchangeId`, `threadId` and the exchange `input`. An exchange that + suspends on a durable call keeps its span open until the resumed job + finishes the exchange. + +Locally: + +```bash +uv run uipath dev # chat in the browser +uv run uipath run agent --file input.json # first message +uv run uipath run agent --file next_message.json --resume # the next ones +``` + +`uipath run` prints the state events (tool calls) but not the reply, which +is a chat event; to see the envelopes a chat host gets, run as a deployed +chat job does: set `UIPATH_JOB_KEY` to any GUID, +`CAS_WEBSOCKET_DISABLED=true`, and the conversation ids in `uipath.json` +(`"fpsProperties": {"conversationalService.conversationId": "...", +"conversationalService.exchangeId": "..."}`); each envelope is then logged +to `__uipath/execution.log`. See [`samples/chat-agent`](samples/chat-agent). + +Not supported yet: in-chat tool confirmation (`requireConfirmation`) and +client-side tools; images sent to the model as images (a chat file is an +attachment for tools). ## Tracing @@ -838,7 +920,9 @@ uses the same span types as low-code agents: * `Agent run - ` (`agentRun`): prompt, input, schemas, output, and `status` (successful or faulted). It opens in the first execution and - closes in the last one. + closes in the last one. A conversational agent has one `Conversational + agent run - ` per chat exchange instead (see "Conversational + agents"). * `LLM call` (`llmCall`): one per model request, with the output text, tool calls and token usage. The shim sends the trace context to the LLM Gateway, which adds its `Model run` span underneath. @@ -1055,9 +1139,8 @@ For example: ## Not supported -* **Conversational agents.** There is no chat runtime yet, so a Codex agent - cannot run as a conversational agent (`uipath dev` chat mode, chat in - Studio Web or Orchestrator). +* **Tool confirmation in a chat** (`requireConfirmation`) and client-side + tools (see "Conversational agents"). * **Breakpoints in `uipath debug`.** Codex offers no hook to stop between steps, so the runtime logs a warning and the run continues to completion. * **Models other than OpenAI** (see [Models](#models)). diff --git a/packages/uipath-codex-sdk/pyproject.toml b/packages/uipath-codex-sdk/pyproject.toml index f441b057..3a5ee992 100644 --- a/packages/uipath-codex-sdk/pyproject.toml +++ b/packages/uipath-codex-sdk/pyproject.toml @@ -42,6 +42,7 @@ dev = [ "ruff>=0.9.4", "pytest>=7.4.0", "pytest-asyncio>=1.0.0", + "uipath-dev>=0.0.85", ] [tool.mypy] diff --git a/packages/uipath-codex-sdk/samples/chat-agent/.env.example b/packages/uipath-codex-sdk/samples/chat-agent/.env.example new file mode 100644 index 00000000..10e8e6b6 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/.env.example @@ -0,0 +1,5 @@ +# Populated by `uipath auth` +UIPATH_URL= +UIPATH_ACCESS_TOKEN= +UIPATH_TENANT_ID= +UIPATH_ORGANIZATION_ID= diff --git a/packages/uipath-codex-sdk/samples/chat-agent/README.md b/packages/uipath-codex-sdk/samples/chat-agent/README.md new file mode 100644 index 00000000..7acfeab5 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/README.md @@ -0,0 +1,126 @@ +# Chat agent (conversational) + +A meeting-room assistant you chat with. It looks rooms up with a local tool +(`find_rooms`, no tenant resource behind it), reads files you attach with +`Analyze_Files`, and remembers what you told it earlier in the conversation. + +The only thing that makes it a chat agent is `uipath.json`: + +```json +{ "runtimeOptions": { "isConversational": true } } +``` + +`main.py` is an ordinary `UiPathCodexAgent` with `base_instructions` as the +system prompt and no `prompt`, `input_schema` or `output_schema` (a chat +agent's input and output are the UiPath conversation messages). + +## How an exchange works + +1. The runtime takes the newest user message from the `messages` input (its + text, plus any uploaded files as job attachments). +2. It resumes the conversation's Codex thread (restored from `state.db`, so + it works on a fresh robot too) and runs one turn with that message. +3. The reply streams as chat events: text chunks as they arrive, and a tool + card for each `find_rooms` / `Analyze_Files` call, on the message that + made it. +4. The thread is saved and the run ends **suspended with no trigger**: the + job waits for the next message, and the chat host closes the exchange. + +## Run locally in the chat UI + +```bash +cd samples/chat-agent +uv sync +uv run uipath auth # pick the tenant, e.g. --alpha +uv run uipath dev +``` + +In the browser, start a **Chat** run on `agent` and ask: + +1. "I need a room for 6 people on Tuesday at 10:00 for the Kingfisher + review. What fits?" A `find_rooms` card shows `{"people": 6}` and its + result, then the reply streams in (Beehive). +2. "Remind me which room you suggested and what the meeting was called." + The answer names Beehive and the Kingfisher review: both come from the + first exchange, so the thread was resumed. + +With a durable tool added (step 5 of "Run on Orchestrator"), an exchange +that waits on a job keeps the run running: `uipath dev` checks the job +every few seconds, and once it has finished the card closes with the +result and the reply continues on its own. Wait for the reply before you +send the next message; one typed while the run is busy is dropped. + +## Run locally from the CLI + +```bash +uv run uipath run agent --file input.json # first message +uv run uipath run agent --file next_message.json --resume # the next one +``` + +`--resume` keeps `__uipath/state.db` (the conversation) between the runs; +without it a local run starts a new conversation. The console reports each +run as successful with an empty output (no `__uipath/output.json` is +written): `SUSPENDED` is what a job (with `UIPATH_JOB_KEY` set) writes to +`__uipath/output.json` and what Orchestrator sees. The console shows the +tool calls (state events) but not the reply, which is a chat event. To see +the reply as a chat host gets it, run as a deployed chat job does: + +```bash +export UIPATH_JOB_KEY=00000000-0000-0000-0000-000000000001 # any GUID +export CAS_WEBSOCKET_DISABLED=true +``` + +and add the conversation ids to `uipath.json`: + +```json +{ + "runtimeOptions": { "isConversational": true }, + "fpsProperties": { + "conversationalService.conversationId": "local-conversation", + "conversationalService.exchangeId": "exchange-1" + } +} +``` + +Then `uv run uipath run agent --file input.json` logs every envelope the +chat bridge would send (`startMessage`, the text chunks, the tool calls, +`endMessage`, `endExchange`) to `__uipath/execution.log` instead of sending +it. Change `exchangeId` and add `--resume` for the next message. Remove +`fpsProperties` again before you pack. + +## Run on Orchestrator + +```bash +uv run uipath pack +uv run uipath publish # pick a folder (or --my-workspace) +``` + +The package's `content/operate.json` carries `"isConversational": true` +(copied from `uipath.json`). + +1. Create the process from the package in that folder (Orchestrator → + Automations → Processes), with a robot or serverless runtime. +2. Open it from the Agents / Autopilot chat, which lists conversational + agents, and start a conversation with the two messages above. +3. Check that: + * the reply streams and the `find_rooms` card shows its input and result; + * the job goes to **Suspended** after each exchange, and the same job + resumes on the next message; + * the trace has one `Conversational agent run - agent` span per exchange, + with `isConversational`, `conversationId`, `exchangeId` and `threadId`. +4. Upload a file (e.g. a meeting agenda PDF) and ask "What is on the + agenda?". An `Analyze_Files` card reads it. +5. Optional, a durable tool inside an exchange: add the + [`durable-process-tool`](../durable-process-tool) sample's `a_plus_b` + process tool to `tools` and ask for a sum. Its card stays open and the + exchange stays open while the job runs; when the job finishes, the job + resumes, the card closes with the result and the reply continues. + +## Trace + +``` +Conversational agent run - agent isConversational, conversationId, exchangeId, threadId +├─ LLM call → Model run +├─ Tool call - find_rooms +└─ LLM call → Model run +``` diff --git a/packages/uipath-codex-sdk/samples/chat-agent/codex.json b/packages/uipath-codex-sdk/samples/chat-agent/codex.json new file mode 100644 index 00000000..61974aef --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/codex.json @@ -0,0 +1,5 @@ +{ + "agents": { + "agent": "main.py:agent" + } +} diff --git a/packages/uipath-codex-sdk/samples/chat-agent/input.json b/packages/uipath-codex-sdk/samples/chat-agent/input.json new file mode 100644 index 00000000..7a28e685 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/input.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "I need a room for 6 people on Tuesday at 10:00 for the Kingfisher review. What fits?" + } + } + ] + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/chat-agent/main.py b/packages/uipath-codex-sdk/samples/chat-agent/main.py new file mode 100644 index 00000000..1f21ab97 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/main.py @@ -0,0 +1,58 @@ +"""Meeting-room assistant: a conversational Codex agent. + +One chat exchange per run. The conversation is one Codex thread, which the +runtime resumes for every new message, so the agent remembers what was said +earlier. Nothing in this file is chat-specific: `uipath.json` sets +`runtimeOptions.isConversational`, and the runtime does the rest. +""" + +import json +from typing import Any + +from uipath_codex_sdk import ToolContext, UiPathCodexAgent, uipath_analyze_files_tool + +ROOMS = [ + {"name": "Aviary", "seats": 4, "floor": 1}, + {"name": "Beehive", "seats": 8, "floor": 2}, + {"name": "Cedar Hall", "seats": 20, "floor": 3}, +] + + +class FindRoomsTool: + """A local, immediate tool: no tenant resource behind it.""" + + name = "find_rooms" + description = "List the meeting rooms that seat at least the given number of people." + input_schema: dict[str, Any] = { + "type": "object", + "properties": { + "people": {"type": "integer", "minimum": 1, "description": "Headcount"} + }, + "required": ["people"], + } + + def validate_args(self, args: dict[str, Any]) -> dict[str, Any]: + people = args.get("people") + if not isinstance(people, int) or people < 1: + raise ValueError("people must be a positive integer") + return {"people": people} + + async def call(self, args: dict[str, Any], ctx: ToolContext) -> str: + matches = [room for room in ROOMS if room["seats"] >= args["people"]] + return json.dumps({"rooms": matches}) + + +agent = UiPathCodexAgent( + model="gpt-5.6-terra", + base_instructions=( + "You help colleagues pick a meeting room. Call find_rooms to see what " + "seats a given headcount, and suggest the smallest room that fits. You " + "can only read the room list: you cannot book, hold or cancel anything, " + "and you know nothing about who is using a room when. Say so plainly " + "when you are asked for something you cannot do. When the user attaches " + "a file (an agenda, a floor plan), read it with Analyze_Files. Answer in " + "one or two sentences, and use what the person already told you earlier " + "in the conversation rather than asking for it again." + ), + tools=[FindRoomsTool(), uipath_analyze_files_tool()], +) diff --git a/packages/uipath-codex-sdk/samples/chat-agent/next_message.json b/packages/uipath-codex-sdk/samples/chat-agent/next_message.json new file mode 100644 index 00000000..ea0d1413 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/next_message.json @@ -0,0 +1,15 @@ +{ + "messages": [ + { + "role": "user", + "contentParts": [ + { + "mimeType": "text/plain", + "data": { + "inline": "Remind me which room you suggested and what the meeting was called." + } + } + ] + } + ] +} diff --git a/packages/uipath-codex-sdk/samples/chat-agent/pyproject.toml b/packages/uipath-codex-sdk/samples/chat-agent/pyproject.toml new file mode 100644 index 00000000..e74a74bc --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "codex-chat-agent" +version = "0.0.1" +description = "UiPath Codex SDK conversational agent (meeting-room assistant)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-codex-sdk>=0.1.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath-codex-sdk/samples/chat-agent/uipath.json b/packages/uipath-codex-sdk/samples/chat-agent/uipath.json new file mode 100644 index 00000000..67dde2f2 --- /dev/null +++ b/packages/uipath-codex-sdk/samples/chat-agent/uipath.json @@ -0,0 +1,5 @@ +{ + "runtimeOptions": { + "isConversational": true + } +} diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py index 3e2228b0..b22977a0 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/agent.py @@ -70,7 +70,8 @@ class UiPathCodexAgent: :class:`~uipath_codex_sdk.UiPathTool`) are answered as soon as their work is done and never suspend the run. codex_home: ``CODEX_HOME`` for the codex process. With durable tools - it defaults to a run-scoped directory under the runtime dir + (and for a conversational agent) it defaults to a run-scoped + directory under the runtime dir (``__uipath/codex_home``), reset together with the state database on a fresh local run; ``~/.codex`` (config.toml, AGENTS.md) is then not read. Without durable tools it defaults to codex's own @@ -78,7 +79,7 @@ class UiPathCodexAgent: hermetic: Pass ``--config`` overrides that stop codex from calling the ChatGPT backend (``features.plugins=false`` and an unreachable ``chatgpt_base_url``). ``None`` (default) turns them on only for - agents with durable tools. + agents with durable tools and conversational agents. durable_tool_instructions: Tag durable tool descriptions with ``[long-running]`` and add one developer instruction about them (call independent ones together; never retry one that is pending). diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py index 9961a9bf..2731c9f7 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/__init__.py @@ -6,6 +6,7 @@ UiPathRuntimeFactoryRegistry, ) +from .chat_runtime import UiPathCodexChatRuntime from .factory import UiPathCodexRuntimeFactory from .runtime import UiPathCodexRuntime @@ -25,4 +26,5 @@ def create_factory( "register_runtime_factory", "UiPathCodexRuntimeFactory", "UiPathCodexRuntime", + "UiPathCodexChatRuntime", ] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_events.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_events.py new file mode 100644 index 00000000..7374ae0d --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_events.py @@ -0,0 +1,563 @@ +"""UiPath conversation (chat) events of a Codex turn, and the chat input. + +:class:`CodexChatEventMapper` turns the thread notifications of one exchange +into ``UiPathConversationMessageEvent`` payloads, in the order the chat host +expects (as LangChain emits them): + +* an assistant message: ``startMessage`` + ``startContentPart`` + (``text/markdown``), one ``chunk`` per ``item/agentMessage/delta`` (or the + whole text when nothing streamed), ``endContentPart``; +* each tool call that follows it attaches to that message: ``startToolCall`` + + ``executingToolCall``, then ``endToolCall`` with its result; +* ``endMessage`` once the message's last tool call has ended, or when the + next message starts, or at the end of the turn. + +MCP tool calls (UiPath tools and your own servers) and shell commands are +tool calls in the chat; reasoning, file edits and web searches stay state +events. The mapper's state (the open messages and tool calls) is saved when +an exchange suspends on a durable call, so the resumed execution can end the +call's ``Tool call`` card on the message that started it. + +The input helpers read the newest user message of a ``{"messages": [...]}`` +input: its text parts, and the files uploaded with it as job attachments. +""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Iterable + +from uipath.core.chat import ( + UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartEndEvent, + UiPathConversationContentPartEvent, + UiPathConversationContentPartStartEvent, + UiPathConversationExecutingToolCallEvent, + UiPathConversationMessageEndEvent, + UiPathConversationMessageEvent, + UiPathConversationMessageStartEvent, + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, + UiPathConversationToolCallStartEvent, +) +from uipath.runtime.events import UiPathRuntimeMessageEvent + +from ..attachments import Attachment + +#: Codex writes markdown; LangChain streams the same mime type. +TEXT_MIME_TYPE = "text/markdown" +#: The chat tool name of a shell command (as its ``Tool call`` span is named). +SHELL_TOOL = "shell" +#: Codex item types shown as tool calls in the chat. +CHAT_TOOL_ITEM_TYPES = frozenset({"mcpToolCall", "commandExecution"}) +#: Prefix of the URI of a file uploaded in the chat. +ATTACHMENT_URN_PREFIX = "urn:uipath:cas:file:orchestrator:" +#: User text of a message that only carries files. +ATTACHMENT_ONLY_TEXT = "The user attached the file(s) below." +#: Roles of a message the user wrote (an empty role counts as the user's). +USER_ROLES = ("user", "") + + +def now_iso() -> str: + """ISO timestamp with milliseconds and ``Z``, as the chat host writes them.""" + now = datetime.now(timezone.utc) + return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" + + +def content_part_id(message_id: str) -> str: + """The id of a message's single text part (LangChain's convention).""" + return f"chunk-{message_id}-0" + + +def _event(message_id: str, **fields: Any) -> UiPathRuntimeMessageEvent: + return UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent(message_id=message_id, **fields), + metadata={"source": "codex"}, + ) + + +def parse_output(value: Any) -> Any: + """A tool result for ``endToolCall``: JSON text parsed, the rest as is.""" + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + return value + return value + + +def _mcp_text(result: Any) -> Any: + """The text of an MCP tool result's first content item, if it has one.""" + if isinstance(result, dict): + content = result.get("content") + if isinstance(content, list) and content: + first = content[0] + if isinstance(first, dict) and "text" in first: + return first["text"] + return result + + +def tool_call_info(item: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]: + """``(tool name, input, metadata)`` of a tool item, for ``startToolCall``. + + An MCP tool is named by its own name, as its ``Tool call`` span is (the + server goes in the metadata); a shell command is ``shell`` with its + ``command`` as the input. + """ + if item.get("type") == "commandExecution": + return SHELL_TOOL, {"command": item.get("command")}, {} + arguments = item.get("arguments") + if isinstance(arguments, str): + arguments = parse_output(arguments) + if not isinstance(arguments, dict): + arguments = {} if arguments is None else {"arguments": arguments} + return ( + str(item.get("tool") or "tool"), + arguments, + {"server": item.get("server")}, + ) + + +def tool_call_result(item: dict[str, Any]) -> tuple[Any, bool]: + """``(output, is_error)`` of a completed tool item, for ``endToolCall``.""" + status = str(item.get("status") or "") + if item.get("type") == "commandExecution": + code = item.get("exit_code") + failed = status in ("failed", "declined") or (code is not None and code != 0) + return item.get("aggregated_output") or "", failed + error = item.get("error") + if error: + message = error.get("message") if isinstance(error, dict) else error + return message, True + return parse_output(_mcp_text(item.get("result"))), status == "failed" + + +# -------------------------------------------------------------------------- +# the mapper +# -------------------------------------------------------------------------- +@dataclass +class _Message: + """An assistant message still open in the chat.""" + + id: str + #: Whether its text part is open (started, not ended). + text_open: bool = False + #: Ids of its tool calls that have not ended. + calls: list[str] = field(default_factory=list) + + def to_stored(self) -> dict[str, Any]: + return {"id": self.id, "text_open": self.text_open, "calls": self.calls} + + +class CodexChatEventMapper: + """Chat events of one exchange, from Codex thread notifications.""" + + def __init__(self) -> None: + self._messages: dict[str, _Message] = {} + #: agentMessage item id -> message id. + self._items: dict[str, str] = {} + #: Tool call id (the item id, also the Responses ``call_id``) -> message id. + self._calls: dict[str, str] = {} + #: The message new tool calls attach to. + self._current: str | None = None + #: agentMessage items whose text arrived as deltas. + self._streamed: set[str] = set() + + # --- persistence -------------------------------------------------------- + + def state(self) -> dict[str, Any]: + """What a resumed execution needs to end the open tool calls.""" + return { + "tool_calls": dict(self._calls), + "open_messages": [m.to_stored() for m in self._messages.values()], + } + + @classmethod + def from_state(cls, state: Any) -> CodexChatEventMapper: + mapper = cls() + if not isinstance(state, dict): + return mapper + for raw in state.get("open_messages") or []: + if isinstance(raw, dict) and isinstance(raw.get("id"), str): + calls = [str(c) for c in raw.get("calls") or []] + mapper._messages[raw["id"]] = _Message( + raw["id"], bool(raw.get("text_open")), calls + ) + for call_id, message_id in (state.get("tool_calls") or {}).items(): + if message_id in mapper._messages: + mapper._calls[str(call_id)] = message_id + return mapper + + @property + def has_open(self) -> bool: + return bool(self._messages) + + # --- notifications -------------------------------------------------------- + + def on_item_started(self, item: dict[str, Any]) -> list[UiPathRuntimeMessageEvent]: + itype = item.get("type") + item_id = item.get("id") + if not isinstance(item_id, str): + return [] + if itype == "agentMessage": + events = self._close_current() + events += self._start_text(item_id) + return events + if itype in CHAT_TOOL_ITEM_TYPES and item_id not in self._calls: + return self._start_call(item_id, *tool_call_info(item)) + return [] + + def on_delta(self, item_id: str, delta: str) -> list[UiPathRuntimeMessageEvent]: + events: list[UiPathRuntimeMessageEvent] = [] + if item_id not in self._items: + events += self._close_current() + events += self._start_text(item_id) + self._streamed.add(item_id) + if delta: + events.append(self._chunk(self._items[item_id], delta)) + return events + + def on_item_completed( + self, item: dict[str, Any] + ) -> list[UiPathRuntimeMessageEvent]: + itype = item.get("type") + item_id = item.get("id") + if not isinstance(item_id, str): + return [] + events: list[UiPathRuntimeMessageEvent] = [] + if itype == "agentMessage": + if item_id not in self._items: + events += self._close_current() + events += self._start_text(item_id) + message_id = self._items[item_id] + text = item.get("text") or "" + if item_id not in self._streamed and text: + events.append(self._chunk(message_id, text)) + message = self._messages.get(message_id) + if message is not None and message.text_open: + message.text_open = False + events.append( + _event( + message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id(message_id), + end=UiPathConversationContentPartEndEvent(), + ), + ) + ) + # Stays open: the tool calls that follow attach to it. + return events + if itype in CHAT_TOOL_ITEM_TYPES: + if item_id not in self._calls: + events += self._start_call(item_id, *tool_call_info(item)) + output, is_error = tool_call_result(item) + events += self._end_call(item_id, output=output, is_error=is_error) + return events + + def on_resumed_call( + self, + call_id: str, + tool: str, + arguments: Any, + output: Any, + is_error: bool, + ) -> list[UiPathRuntimeMessageEvent]: + """A durable call that finished while the exchange was suspended.""" + events: list[UiPathRuntimeMessageEvent] = [] + if call_id not in self._calls: + # Its start was not saved (an older state): show it whole. + args = arguments if isinstance(arguments, dict) else {} + events += self._start_call(call_id, tool, args, {"server": None}) + events += self._end_call( + call_id, output=parse_output(output), is_error=is_error + ) + return events + + def cancel_calls(self, keep: Iterable[str] = ()) -> list[UiPathRuntimeMessageEvent]: + """End every open tool call but ``keep`` as cancelled.""" + kept = set(keep) + events: list[UiPathRuntimeMessageEvent] = [] + for call_id in [c for c in self._calls if c not in kept]: + events += self._end_call(call_id, cancelled=True) + return events + + def close_turn(self) -> list[UiPathRuntimeMessageEvent]: + """End everything still open: the turn is over.""" + events = self.cancel_calls() + for message_id in list(self._messages): + events += self._end_message(message_id) + self._current = None + return events + + # --- builders ------------------------------------------------------------- + + def _new_message(self, *, text: bool) -> tuple[str, UiPathRuntimeMessageEvent]: + message_id = str(uuid.uuid4()) + self._messages[message_id] = _Message(message_id, text_open=text) + self._current = message_id + start = UiPathConversationMessageStartEvent( + role="assistant", timestamp=now_iso() + ) + if not text: + return message_id, _event(message_id, start=start) + return message_id, _event( + message_id, + start=start, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id(message_id), + start=UiPathConversationContentPartStartEvent(mime_type=TEXT_MIME_TYPE), + ), + ) + + def _start_text(self, item_id: str) -> list[UiPathRuntimeMessageEvent]: + message_id, event = self._new_message(text=True) + self._items[item_id] = message_id + return [event] + + @staticmethod + def _chunk(message_id: str, text: str) -> UiPathRuntimeMessageEvent: + return _event( + message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id(message_id), + chunk=UiPathConversationContentPartChunkEvent(data=text), + ), + ) + + def _start_call( + self, + call_id: str, + tool: str, + tool_input: dict[str, Any], + metadata: dict[str, Any], + ) -> list[UiPathRuntimeMessageEvent]: + events: list[UiPathRuntimeMessageEvent] = [] + message = self._messages.get(self._current) if self._current else None + if message is None or message.text_open: + events += self._close_current() + message_id, start = self._new_message(text=False) + events.append(start) + message = self._messages[message_id] + message.calls.append(call_id) + self._calls[call_id] = message.id + meta = {k: v for k, v in metadata.items() if v is not None} + events.append( + _event( + message.id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=call_id, + start=UiPathConversationToolCallStartEvent( + tool_name=tool, + timestamp=now_iso(), + input=tool_input, + metadata=meta or None, + ), + ), + ) + ) + # No confirmation is asked for: the call runs straight away. + events.append( + _event( + message.id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=call_id, + executing=UiPathConversationExecutingToolCallEvent( + timestamp=now_iso(), input=tool_input + ), + ), + ) + ) + return events + + def _end_call( + self, + call_id: str, + *, + output: Any = None, + is_error: bool = False, + cancelled: bool = False, + ) -> list[UiPathRuntimeMessageEvent]: + message_id = self._calls.pop(call_id, None) + if message_id is None: + return [] + events = [ + _event( + message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=call_id, + end=UiPathConversationToolCallEndEvent( + timestamp=now_iso(), + output=output, + is_error=is_error, + cancelled=True if cancelled else None, + ), + ), + ) + ] + message = self._messages.get(message_id) + if message is not None: + if call_id in message.calls: + message.calls.remove(call_id) + if not message.calls and not message.text_open: + events += self._end_message(message_id) + return events + + def _close_current(self) -> list[UiPathRuntimeMessageEvent]: + """End the current message unless tool calls still run on it.""" + message = self._messages.get(self._current) if self._current else None + self._current = None + if message is None or message.calls: + return [] + return self._end_message(message.id) + + def _end_message(self, message_id: str) -> list[UiPathRuntimeMessageEvent]: + message = self._messages.pop(message_id, None) + if message is None: + return [] + if self._current == message_id: + self._current = None + events: list[UiPathRuntimeMessageEvent] = [] + for call_id in list(message.calls): + self._calls.pop(call_id, None) + if message.text_open: + events.append( + _event( + message_id, + content_part=UiPathConversationContentPartEvent( + content_part_id=content_part_id(message_id), + end=UiPathConversationContentPartEndEvent(), + ), + ) + ) + events.append(_event(message_id, end=UiPathConversationMessageEndEvent())) + return events + + +# -------------------------------------------------------------------------- +# the chat input +# -------------------------------------------------------------------------- +def _messages_of(input: Any) -> list[Any] | None: + """The top-level ``messages`` list of an input, or None. + + A resume map's values are not looked into: a durable call's job output + may hold chat-shaped messages (a conversational agent run as a process + does), and is still that call's result, not a user message. Codex makes + no API trigger, so the chat host never resumes a run with an + ``{interrupt_id: {"messages": [...]}}`` map. + """ + if not isinstance(input, dict): + return None + messages = input.get("messages") + return messages if isinstance(messages, list) else None + + +def is_chat_input(input: Any) -> bool: + """Whether ``input`` carries chat messages (a user's new message).""" + return _messages_of(input) is not None + + +def _dump(message: Any) -> dict[str, Any] | None: + if isinstance(message, dict): + return message + dump = getattr(message, "model_dump", None) + if callable(dump): + try: + dumped = dump(by_alias=True, mode="json") + except Exception: + return None + return dumped if isinstance(dumped, dict) else None + return None + + +def _parts(message: dict[str, Any]) -> list[dict[str, Any]]: + parts = message.get("contentParts") + return [p for p in parts if isinstance(p, dict)] if isinstance(parts, list) else [] + + +def extract_user_message(input: Any) -> dict[str, Any] | None: + """The newest message the user wrote (text or files), camelCase, or None. + + Only that message goes to the model: everything earlier is already in + the Codex thread, so it does not matter whether the host replays the + history or sends only the new message. + """ + messages = _messages_of(input) + for raw in reversed(messages or []): + message = _dump(raw) + if message is None: + continue + if str(message.get("role") or "") not in USER_ROLES: + continue + if user_text(message) or chat_attachments(message): + return message + return None + + +def user_text(message: dict[str, Any]) -> str: + """The inline text parts of a message, joined.""" + texts: list[str] = [] + for part in _parts(message): + mime = str(part.get("mimeType") or "text/plain") + data = part.get("data") + if not mime.startswith("text/") or not isinstance(data, dict): + continue + inline = data.get("inline") + if isinstance(inline, str) and inline: + texts.append(inline) + return "\n".join(texts) + + +def chat_attachments(message: dict[str, Any]) -> list[Attachment]: + """The files uploaded with a message, as job attachments. + + A file part's ``data.uri`` is ``urn:uipath:cas:file:orchestrator:``; + the id is the Orchestrator attachment's, as LangChain reads it. + """ + found: list[Attachment] = [] + for part in _parts(message): + data = part.get("data") + uri = data.get("uri") if isinstance(data, dict) else None + if not isinstance(uri, str) or not uri.startswith(ATTACHMENT_URN_PREFIX): + continue + try: + attachment_id = uuid.UUID(uri.rsplit(":", 1)[-1]) + except ValueError: + continue + found.append( + Attachment( + id=attachment_id, + full_name=str(part.get("name") or attachment_id), + mime_type=str(part.get("mimeType") or "application/octet-stream"), + ) + ) + return found + + +def message_fingerprint(message: dict[str, Any]) -> str: + """A message's identity: its ``messageId``, else a hash of its parts.""" + message_id = message.get("messageId") + if isinstance(message_id, str) and message_id: + return message_id + blob = json.dumps(_parts(message), sort_keys=True, default=str) + return "sha256:" + hashlib.sha256(blob.encode()).hexdigest() + + +__all__ = [ + "ATTACHMENT_ONLY_TEXT", + "CodexChatEventMapper", + "TEXT_MIME_TYPE", + "chat_attachments", + "content_part_id", + "extract_user_message", + "is_chat_input", + "message_fingerprint", + "now_iso", + "parse_output", + "tool_call_info", + "tool_call_result", + "user_text", +] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_runtime.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_runtime.py new file mode 100644 index 00000000..61b331b9 --- /dev/null +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/chat_runtime.py @@ -0,0 +1,440 @@ +"""Conversational (chat) runtime: one chat exchange per run. + +A conversation is one Codex thread. Each run takes the newest user message +from the ``messages`` input, resumes the thread (restoring its rollout from +the state database first, as a durable resume does), runs one turn with that +message, and streams the reply as UiPath conversation message events (see +:mod:`.chat_events`). The rollout is then checkpointed and the run ends +**SUSPENDED with no trigger**: the job stays alive for the next message, and +the chat host (``UiPathChatRuntime``) reads "no trigger" as the end of the +exchange and sends ``endExchange``. + +Which case a run is in is read from the stored state, never from the resume +flag: ``uipath dev`` runs every exchange as a fresh execution (``resume`` is +False, same runtime id), Orchestrator as a job resume. + +* the exchange is parked (it suspended on durable calls, and the chat state + says so): the run is the durable resume (results injected, the model + continues). A new user message arriving meanwhile is not processed: the + run suspends again on the same calls; +* the conversation has a thread: the next exchange (whatever an exchange + that faulted left behind, such as the pending call of a job it started, + is dropped); +* nothing is stored: the first exchange. + +The chat events of a parked exchange's open message belong to that +exchange: the chat host sends every event under the exchange it serves. A +resume that runs for another exchange (a new message met calls that had +all finished, or the run serves another exchange id than the one that +parked) ends the old message silently; the parked exchange's tool card +stays open in the chat, as nothing can close it from another exchange. +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncGenerator + +from uipath.runtime import ( + UiPathRuntimeEvent, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.errors import UiPathErrorCategory +from uipath.runtime.schema import UiPathRuntimeSchema + +from ..attachments import AttachmentStore, attachments_block +from ..tool_results import ToolResultStore +from ..tools import UiPathDurableTool +from .chat_events import ( + ATTACHMENT_ONLY_TEXT, + CodexChatEventMapper, + chat_attachments, + extract_user_message, + message_fingerprint, + user_text, +) +from .errors import UiPathCodexErrorCode, UiPathCodexRuntimeError +from .runtime import ( + CONTINUE_PROMPT, + UiPathCodexRuntime, + _ItemTracker, + _ResumePlan, +) +from .schema import get_entrypoint_schema +from .session_store import ( + STATE_STARTED, + CodexSessionStore, + EntrypointRecord, + PendingCall, +) +from .tool_host import DurableToolHost + +logger = logging.getLogger(__name__) + +#: Key of the chat state that holds the exchange's own user message. +USER_MESSAGE_KEY = "user_message" +#: Key of the chat state that says the exchange is parked on durable calls. +PARKED_KEY = "parked" +#: Key of the chat state that holds the id of the exchange that parked. +EXCHANGE_KEY = "exchange_id" +#: Durable call results that count as a failed tool call in the chat. +_ERROR_STATUSES = frozenset({"faulted", "error", "failed"}) + + +class UiPathCodexChatRuntime(UiPathCodexRuntime): + """Runs a :class:`UiPathCodexAgent` as a conversational agent. + + Selected by the factory when ``uipath.json`` sets + ``runtimeOptions.isConversational`` (or the chat host passes a + conversation id). The agent's ``base_instructions`` are its system + prompt; ``prompt``, ``input_schema`` and ``output_schema`` do not apply + (the platform feeds the messages, the reply goes over the chat bridge) + and are refused. + """ + + _conversational = True + + def __init__(self, *, exchange_id: str | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + refused = [ + name + for name in ("prompt", "input_schema", "output_schema") + if getattr(self.agent, name) is not None + ] + if refused: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.CONFIG_INVALID, + "Invalid conversational agent", + "A conversational agent takes its input from the chat and " + "answers in it, so it cannot set " + + ", ".join(refused) + + ". Remove them (use base_instructions for the system prompt), " + "or turn runtimeOptions.isConversational off in uipath.json.", + UiPathErrorCategory.USER, + ) + self._mapper = CodexChatEventMapper() + #: Fingerprint of the user message this exchange answers. + self._user_message: str | None = None + #: The chat host's exchange this run serves (None outside a chat job). + self._exchange_id = exchange_id + #: Whether the exchange is parked on durable calls (saved in the state). + self._parked = False + #: The exchange that parked (its open message's events belong to it). + self._parked_exchange: str | None = None + + async def get_schema(self) -> UiPathRuntimeSchema: + return get_entrypoint_schema(self.agent, self.entrypoint, conversational=True) + + # --- the exchange --------------------------------------------------------- + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Run one chat exchange (or resume one parked on durable calls).""" + self._refuse_breakpoints(options) + tools = self._durable_tools() + session = self._require_session() + await self._check_entrypoint(session) + stored = await session.get_chat() + pending = { + cid: call + for cid, call in (await session.get_pending()).items() + if call.state == STATE_STARTED + } + # Only an exchange that suspended is parked: an exchange that faulted + # may leave the pending call of a job it started, which no execution + # will resume. + parked = chat_parked(stored) and bool( + pending or await session.get_completed() or await session.get_dangling() + ) + message = extract_user_message(input) + plan: _ResumePlan | None + if parked: + self._mapper = CodexChatEventMapper.from_state(stored) + self._user_message = stored.get(USER_MESSAGE_KEY) + self._parked = True + self._parked_exchange = stored.get(EXCHANGE_KEY) + new_message = self._is_new_message(input, message) + if pending and new_message: + logger.warning( + "A user message arrived while the exchange waits on durable " + "tool call(s) %s. It is not processed: the exchange keeps " + "waiting on the call(s).", + sorted(pending), + ) + yield self._suspended_result(pending) + return + # The open message's events go to the exchange that parked only. + elsewhere = new_message or self._serves_another_exchange() + if elsewhere: + logger.info( + "Resuming the exchange parked by %s in exchange %s: its " + "open message ends without chat events.", + self._parked_exchange or "an earlier exchange", + self._exchange_id or "of a new message", + ) + self._resumed_events = [] + self._attachments = await self._load_attachments() + self._results = await self._load_results() + prepared = await self._prepare_resume(input) + for event in self._resumed_events: + yield event + chat_events = self._resumed_chat_events(event) + if not elsewhere: + for chat_event in chat_events: + yield chat_event + self._resumed_events = [] + if isinstance(prepared, UiPathRuntimeResult): + await self._save_chat_state() + yield prepared + return + plan = prepared + prompt = CONTINUE_PROMPT + if elsewhere: + # Its open message is not this exchange's to end. + self._mapper = CodexChatEventMapper() + if new_message: + # Every call had finished: the results go in, then the message. + prompt = self._chat_prompt(input) + self._user_message = self._fingerprint(input, message) + else: + prompt, plan = await self._start_exchange(session, input, message) + # Until it suspends again: a run that faults leaves nothing parked. + self._parked = False + self._parked_exchange = None + await self._save_attachments(force=True) + await self._save_results(force=True) + await self._save_chat_state() + async for event in self._run(prompt, tools, plan): + yield event + + async def _start_exchange( + self, + session: CodexSessionStore, + input: Any, + message: dict[str, Any] | None, + ) -> tuple[str, _ResumePlan | None]: + """The prompt and thread of a new exchange (the first, or a next one).""" + if self._fingerprint(input, message) is None: + raise self._no_message_error() + thread = await session.get_thread() + rollout = await session.get_rollout() + # A trigger left over from an earlier suspension would keep the + # exchange from ending (the host waits on it). + save_triggers = getattr(self.storage, "save_triggers", None) + plan: _ResumePlan | None = None + if thread is not None and rollout is not None: + # What an exchange that faulted left (the pending call of a job + # it started...): nothing resumes it, and a later suspension would + # take it for one of its own calls. + await session.clear_suspension() + self._attachments = await self._load_attachments() + self._results = await self._load_results() + plan = _ResumePlan(thread_id=thread.thread_id, items=[]) + logger.info( + "Chat exchange on Codex thread %s (conversation %s)", + thread.thread_id, + self.runtime_id, + ) + else: + if thread is not None: + logger.warning( + "Codex thread %s has no saved conversation (its first " + "exchange did not finish); starting a new thread.", + thread.thread_id, + ) + await session.clear_all() + await session.set_entrypoint(EntrypointRecord(self.entrypoint)) + self._attachments = AttachmentStore() + self._results = ToolResultStore() + if save_triggers is not None: + await save_triggers(self.runtime_id, []) + self._mapper = CodexChatEventMapper() + prompt = self._chat_prompt(input) + self._user_message = self._fingerprint(input, message) + return prompt, plan + + async def _check_entrypoint(self, session: CodexSessionStore) -> None: + record = await session.get_entrypoint() + if record is not None and record.entrypoint != self.entrypoint: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ENTRYPOINT_MISMATCH, + "Resumed on a different entrypoint", + f"The conversation started on '{record.entrypoint}' and cannot " + f"continue on '{self.entrypoint}'.", + UiPathErrorCategory.USER, + ) + + # --- the user message ------------------------------------------------------- + + @staticmethod + def _fingerprint(input: Any, message: dict[str, Any] | None) -> str | None: + if message is not None: + return message_fingerprint(message) + if isinstance(input, str) and input.strip(): + return message_fingerprint( + {"contentParts": [{"data": {"inline": input.strip()}}]} + ) + return None + + def _serves_another_exchange(self) -> bool: + """Whether this run serves another exchange than the one that parked.""" + return ( + self._exchange_id is not None + and self._parked_exchange is not None + and self._exchange_id != self._parked_exchange + ) + + def _is_new_message(self, input: Any, message: dict[str, Any] | None) -> bool: + """Whether ``input`` is a user message other than this exchange's own. + + A job resumed for a durable call may be handed the input it started + with again; that message was answered already and is no new one. + """ + fingerprint = self._fingerprint(input, message) + return fingerprint is not None and fingerprint != self._user_message + + def _chat_prompt(self, input: Any) -> str: + """The turn's user message: the newest message's text and files. + + Files uploaded in the chat become job attachments the run knows (for + attachment tools, in this exchange and the next ones), listed in the + prompt as the input attachments of a non-chat run are. + """ + message = extract_user_message(input) + if message is None: + if isinstance(input, str) and input.strip(): + return input.strip() + raise self._no_message_error() + text = user_text(message) + attachments = chat_attachments(message) + if not attachments: + return text + for attachment in attachments: + self._attachments.register(attachment) + block = attachments_block(attachments) + return f"{text or ATTACHMENT_ONLY_TEXT}\n\n{block}" + + @staticmethod + def _no_message_error() -> UiPathCodexRuntimeError: + return UiPathCodexRuntimeError( + UiPathCodexErrorCode.INPUT_INVALID, + "No user message", + "A conversational agent's input is " + '{"messages": [{"role": "user", "contentParts": [...]}]} with ' + "text or a file in the newest user message.", + UiPathErrorCategory.USER, + ) + + async def _resume_values( + self, input: Any, pending: dict[str, PendingCall] + ) -> dict[str, Any]: + """As the base, but a chat message answers no durable call.""" + if extract_user_message(input) is not None or isinstance(input, str): + ordered = sorted(pending.values(), key=lambda c: c.order) + return await self._read_pending_jobs(ordered, stored_too=True) + return await super()._resume_values(input, pending) + + # --- events ----------------------------------------------------------------- + + def _item_started_events(self, item: dict[str, Any]) -> list[UiPathRuntimeEvent]: + return list(self._mapper.on_item_started(item)) + + def _item_completed_events(self, item: dict[str, Any]) -> list[UiPathRuntimeEvent]: + events: list[UiPathRuntimeEvent] = list(self._mapper.on_item_completed(item)) + if item.get("type") != "agentMessage": + # Tools, reasoning, file edits...: the state events of a non-chat + # run too (the chat host passes them on to the debugger). + events.extend(super()._item_completed_events(item)) + return events + + def _notification_events(self, payload: Any) -> list[UiPathRuntimeEvent]: + from openai_codex.generated.v2_all import AgentMessageDeltaNotification + + if isinstance(payload, AgentMessageDeltaNotification): + return list(self._mapper.on_delta(payload.item_id, payload.delta)) + return [] + + def _resumed_chat_events( + self, event: UiPathRuntimeEvent + ) -> list[UiPathRuntimeEvent]: + """``endToolCall`` of a durable call that finished while suspended.""" + metadata = getattr(event, "metadata", None) or {} + payload = getattr(event, "payload", None) + if metadata.get("item_type") != "durableToolResult" or not isinstance( + payload, dict + ): + return [] + return list( + self._mapper.on_resumed_call( + str(metadata.get("call_id")), + str(payload.get("tool")), + payload.get("arguments"), + payload.get("result"), + str(payload.get("status")) in _ERROR_STATUSES, + ) + ) + + async def _completed_events( + self, + codex_home: str, + thread_id: str | None, + tools: dict[str, UiPathDurableTool], + items: list[dict[str, Any]], + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """End the exchange: checkpoint the thread, then SUSPENDED on nothing.""" + session = self._require_session() + if thread_id is not None: + await self._checkpoint_rollout(codex_home, thread_id) + await self._save_attachments() + await self._save_results() + await session.clear_suspension() + for event in self._mapper.close_turn(): + yield event + await self._save_chat_state() + logger.info( + "Chat exchange done on Codex thread %s (conversation %s)", + thread_id, + self.runtime_id, + ) + yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUSPENDED) + + async def _suspend_events( + self, + codex_home: str, + thread_id: str, + tracker: _ItemTracker, + host: DurableToolHost | None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Park the exchange on its durable calls; their cards stay open.""" + result = await self._finish_suspend(codex_home, thread_id, tracker, host) + held = set(result.output) if isinstance(result.output, dict) else set() + # Calls cut off by the suspension never report back. + for event in self._mapper.cancel_calls(keep=held): + yield event + self._parked = True + self._parked_exchange = self._exchange_id + await self._save_chat_state() + yield result + + async def _save_chat_state(self) -> None: + await self._require_session().set_chat( + { + **self._mapper.state(), + USER_MESSAGE_KEY: self._user_message, + PARKED_KEY: self._parked, + EXCHANGE_KEY: self._parked_exchange, + } + ) + + +def chat_parked(state: Any) -> bool: + """Whether a stored chat state is of an exchange parked on durable calls.""" + return isinstance(state, dict) and state.get(PARKED_KEY) is True + + +__all__ = ["UiPathCodexChatRuntime", "chat_parked"] diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py index 5a6cd631..f2720858 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/factory.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging import os import shutil @@ -28,6 +29,8 @@ from ..agent import UiPathCodexAgent from ..attachments import span_attachments from ..tools import is_durable_tool +from .chat_events import chat_attachments, extract_user_message, is_chat_input +from .chat_runtime import UiPathCodexChatRuntime, chat_parked from .config import CodexConfig from .errors import UiPathCodexErrorCode, UiPathCodexRuntimeError from .loader import CodexAgentLoader @@ -72,6 +75,9 @@ class UiPathCodexRuntimeFactory: Every runtime is wrapped in ``UiPathResumableRuntime`` over a SQLite state database, so a durable-tool suspension is persisted as resume triggers and restored on the next ``--resume`` (or job resume). + + A conversational agent (``uipath.json`` → ``runtimeOptions.isConversational``, + or a chat host's conversation id) runs on :class:`UiPathCodexChatRuntime`. """ def __init__(self, context: UiPathRuntimeContext): @@ -113,27 +119,38 @@ async def new_runtime( agent = await self._resolve_agent(entrypoint) loader = self._agent_loaders[entrypoint] storage = await self._get_storage() - delegate = UiPathCodexRuntime( - agent=agent, - runtime_id=runtime_id, - entrypoint=entrypoint, - file_path=os.path.abspath(loader.file_path), - storage=storage, - # Only durable agents run on a run-scoped CODEX_HOME; the others - # (immediate tools only, too) keep codex's own resolution (and - # nothing of theirs is wiped). - codex_home=( + conversational = self._is_conversational() + options: dict[str, Any] = { + "agent": agent, + "runtime_id": runtime_id, + "entrypoint": entrypoint, + "file_path": os.path.abspath(loader.file_path), + "storage": storage, + # Only durable and conversational agents run on a run-scoped + # CODEX_HOME; the others (immediate tools only, too) keep codex's + # own resolution (and nothing of theirs is wiped). + "codex_home": ( self._get_codex_home() - if any(is_durable_tool(t) for t in agent.tools or []) + if ( + conversational or any(is_durable_tool(t) for t in agent.tools or []) + ) and not agent.codex_home else None ), + } + delegate: UiPathCodexRuntime = ( + UiPathCodexChatRuntime(exchange_id=self.context.exchange_id, **options) + if conversational + else UiPathCodexRuntime(**options) ) return UiPathCodexResumableRuntime( delegate=delegate, storage=storage, trigger_manager=CodexResumeTriggerHandler(), runtime_id=runtime_id, + conversational=conversational, + conversation_id=self.context.conversation_id, + exchange_id=self.context.exchange_id, ) async def dispose(self) -> None: @@ -146,6 +163,25 @@ async def dispose(self) -> None: if storage is not None: await storage.dispose() + def _is_conversational(self) -> bool: + """Whether the agent runs as a conversational (chat) agent. + + The chat host's conversation id is the live signal; otherwise + ``uipath.json`` → ``runtimeOptions.isConversational`` decides (what + ``uipath pack`` copies into the package, and what ``uipath dev`` + runs locally). A missing or unreadable file means no. + """ + if self.context.conversation_id is not None: + return True + config_path = self.context.config_path or "uipath.json" + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + except (OSError, ValueError): + return False + options = config.get("runtimeOptions") if isinstance(config, dict) else None + return isinstance(options, dict) and bool(options.get("isConversational")) + # --- state ------------------------------------------------------------ def _should_reset_state(self) -> bool: @@ -262,8 +298,39 @@ class UiPathCodexResumableRuntime(UiPathResumableRuntime): ``uipath run`` prints and what lands in ``output.json`` (Orchestrator resumes from ``resumeTriggers``), so it is replaced by ``{call_id: {tool, arguments, job_key, job_state, folder_key}}``. + + A ``conversational`` runtime traces each chat exchange as its own + ``Conversational agent run`` span: an exchange that ends (SUSPENDED with + no trigger) completes it, and only an exchange parked on durable calls + saves it for the execution that resumes the exchange. """ + def __init__( + self, + *args: Any, + conversational: bool = False, + conversation_id: str | None = None, + exchange_id: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self.conversational = conversational + self.conversation_id = conversation_id + self.exchange_id = exchange_id + + async def _restore_resume_input( + self, input: dict[str, Any] | None + ) -> dict[str, Any] | None: + """Keep the stored triggers when a chat message is the resume input. + + The base class takes any explicit input as the answer to the stored + trigger(s) and deletes them. A user message answers no durable call: + the exchange suspends again on the same calls (with their triggers). + """ + if self.conversational and (is_chat_input(input) or isinstance(input, str)): + return input + return await super()._restore_resume_input(input) + async def execute( self, input: dict[str, Any] | None = None, @@ -310,7 +377,16 @@ async def _open_agent_run(self, input: Any, options: Any) -> _AgentRun: if store is not None: try: saved = await store.get_trace() - if not resumed and saved is not None: + # A chat exchange parked on durable calls keeps its span even + # when the next run is not a resume (a fresh execution on the + # same runtime id). Only a parked exchange: a faulted one may + # leave pending calls, but its span has ended. + parked = ( + self.conversational + and saved is not None + and chat_parked(await store.get_chat()) + ) + if not resumed and saved is not None and not parked: await store.set_trace(None) saved = None except Exception: @@ -337,9 +413,15 @@ async def _open_agent_run(self, input: Any, options: Any) -> _AgentRun: self._start_agent_span(input, options), store=store, delegate=delegate, + conversational=self.conversational, ) return _AgentRun( - root, store=store, delegate=delegate, saved=saved, propagated=propagated + root, + store=store, + delegate=delegate, + saved=saved, + propagated=propagated, + conversational=self.conversational, ) def _start_agent_span(self, input: Any, options: Any) -> Span: @@ -348,6 +430,8 @@ def _start_agent_span(self, input: Any, options: Any) -> Span: if not isinstance(runtime, UiPathCodexRuntime): # pragma: no cover return trace.get_tracer(__name__).start_span("Agent run") agent = runtime.agent + if isinstance(runtime, UiPathCodexChatRuntime): + return self._start_exchange_span(runtime, input) user_prompt: str | None = CONTINUE_PROMPT if resumed else None if not resumed: try: @@ -372,6 +456,40 @@ def _start_agent_span(self, input: Any, options: Any) -> Span: ), ) + def _start_exchange_span(self, runtime: UiPathCodexChatRuntime, input: Any) -> Span: + """A chat exchange's span, named and shaped as low-code's. + + Its ``input`` is the exchange input (the messages); there is no + ``userPrompt``, and no ``resumed``: a span opened here is always a + new exchange (every exchange after the first is a job resume in + Orchestrator), and the durable resume of a parked exchange reuses its + saved span. ``threadId`` is set when the exchange ends. + """ + agent = runtime.agent + schema = get_entrypoint_schema(agent, runtime.entrypoint, conversational=True) + message = extract_user_message(input) + return start_agent_run( + runtime.entrypoint, + agent_name=agent.name if agent.name != "agent" else runtime.entrypoint, + model=agent.model, + input=input if isinstance(input, dict) else None, + system_prompt=agent.base_instructions, + user_prompt=None, + input_schema=schema.input, + output_schema=schema.output, + resumed=False, + attachments=( + span_attachments(chat_attachments(message), "in") + if message is not None + else None + ), + is_conversational=True, + extra={ + "conversationId": self.conversation_id or self.runtime_id, + "exchangeId": self.exchange_id, + }, + ) + async def _handle_suspension( self, result: UiPathRuntimeResult ) -> UiPathRuntimeResult: @@ -451,9 +569,11 @@ def __init__( delegate: Any, saved: dict[str, Any] | None = None, propagated: dict[str, Any] | None = None, + conversational: bool = False, ) -> None: self.root = root self.saved = saved + self._conversational = conversational # Attributes this execution's spans carry (``execution.id``) that the # restored root cannot hand down. self._propagated = dict(propagated or {}) @@ -484,6 +604,15 @@ async def finish(self, result: UiPathRuntimeResult) -> None: if self._done: return self._done = True + if ( + result.status == UiPathRuntimeStatus.SUSPENDED + and self._conversational + and not result.triggers + ): + # The chat exchange is over (the job waits for the next message). + extra = await self._chat_attributes() + await self._safely(self._complete("successful", extra=extra)) + return if result.status == UiPathRuntimeStatus.SUSPENDED: try: await self._suspend(result.output) @@ -533,22 +662,42 @@ async def _safely(step: Any) -> None: except Exception: logger.warning("Could not finish the Agent run trace", exc_info=True) + async def _chat_attributes(self) -> dict[str, Any]: + """``threadId`` of a chat exchange: the conversation's Codex thread.""" + if not self._conversational or self._store is None: + return {} + try: + thread = await self._store.get_thread() + except Exception: + logger.debug("could not read the Codex thread id", exc_info=True) + return {} + return {"threadId": thread.thread_id} if thread is not None else {} + async def _suspend(self, output: Any) -> None: if self.saved is not None: return # still running; the saved span carries forward unchanged # Not ended: the next execution completes it. set_attributes(self.root, status="suspended", output=output) + for key, value in (await self._chat_attributes()).items(): + self.root.set_attribute(key, value) push_running(self.root) record = span_record(self.root) if record is not None and self._store is not None: await self._store.set_trace(record) async def _complete( - self, status: str, *, output: Any = None, error: str | None = None + self, + status: str, + *, + output: Any = None, + error: str | None = None, + extra: dict[str, Any] | None = None, ) -> None: if self.saved is None: try: set_attributes(self.root, status=status, output=output) + for key, value in (extra or {}).items(): + self.root.set_attribute(key, value) if error is None: self.root.set_status(Status(StatusCode.OK)) else: @@ -562,6 +711,7 @@ async def _complete( self.saved, attributes={ **self._propagated, + **(extra or {}), **attribute_values(status=status, output=output), }, error=error, diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py index 25634084..50a7ba1e 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/runtime.py @@ -39,6 +39,7 @@ import subprocess import time from dataclasses import dataclass, field +from pathlib import Path from typing import Any, AsyncGenerator, cast from opentelemetry import context as context_api @@ -585,6 +586,10 @@ class _ResumePlan: class UiPathCodexRuntime: """Executes a single :class:`UiPathCodexAgent` via the Codex SDK. + The ``_item_*_events`` / ``_notification_events`` / ``_completed_events`` + / ``_suspend_events`` hooks are what the conversational subclass + (:class:`.chat_runtime.UiPathCodexChatRuntime`) overrides. + Args: agent: The agent definition. runtime_id: UiPath runtime id (the job key when deployed). @@ -598,6 +603,9 @@ class UiPathCodexRuntime: ignore it and keep codex's own ``CODEX_HOME`` resolution. """ + #: A conversational (chat) runtime keeps its Codex thread across runs. + _conversational: bool = False + def __init__( self, *, @@ -844,8 +852,9 @@ async def _run( else: thread = await codex.thread_start(**thread_kwargs) thread_id = thread.id - if host is not None and tools: + if (host is not None and tools) or self._conversational: await self._require_session().set_thread(ThreadRecord(thread.id)) + if host is not None and tools: host.bind(self._rollout_probe(durable_home, thread.id)) run_kwargs: dict[str, Any] = {} @@ -871,14 +880,15 @@ async def _run( tracker.on_started(item) if not _ItemTracker._is_hosted(item): item_spans.start(item) + for event in self._item_started_events(item): + yield event elif isinstance(payload, ItemCompletedNotification): item = payload.item.model_dump(mode="json") tracker.on_completed(item) if not _ItemTracker._is_hosted(item): item_spans.finish(item) item_dicts.append(item) - event = self._item_to_event(item) - if event is not None: + for event in self._item_completed_events(item): yield event elif isinstance(payload, TurnCompletedNotification): if payload.turn.status == TurnStatus.failed: @@ -910,6 +920,9 @@ async def _run( ) elif isinstance(payload, ThreadTokenUsageUpdatedNotification): pass # token usage → tracing milestone + else: + for event in self._notification_events(payload): + yield event except UiPathCodexRuntimeError as e: _raise_fatal(host, e) raise @@ -979,15 +992,56 @@ async def _run( self._shim = None if suspend.killed and thread_id is not None: - yield await self._finish_suspend(durable_home, thread_id, tracker, host) + async for event in self._suspend_events( + durable_home, thread_id, tracker, host + ): + yield event return + async for event in self._completed_events( + durable_home, thread_id, tools, item_dicts + ): + yield event + + # --- hooks of the conversational runtime ------------------------------ + + def _item_started_events(self, item: dict[str, Any]) -> list[UiPathRuntimeEvent]: + """Events for a thread item that started (none: completed items count).""" + return [] + + def _item_completed_events(self, item: dict[str, Any]) -> list[UiPathRuntimeEvent]: + """Events for a completed thread item (see :meth:`_item_to_event`).""" + event = self._item_to_event(item) + return [event] if event is not None else [] + + def _notification_events(self, payload: Any) -> list[UiPathRuntimeEvent]: + """Events for any other turn notification (deltas...): none.""" + return [] + + async def _completed_events( + self, + codex_home: str, + thread_id: str | None, + tools: dict[str, UiPathDurableTool], + items: list[dict[str, Any]], + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """The end of a turn that completed: SUCCESSFUL with the final text.""" if tools: await self._require_session().clear_suspension() yield UiPathRuntimeResult( - output=self._build_output(self._final_text(item_dicts)), + output=self._build_output(self._final_text(items)), status=UiPathRuntimeStatus.SUCCESSFUL, ) + async def _suspend_events( + self, + codex_home: str, + thread_id: str, + tracker: _ItemTracker, + host: DurableToolHost | None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """The end of a turn stopped for durable calls: SUSPENDED on them.""" + yield await self._finish_suspend(codex_home, thread_id, tracker, host) + def _mcp_servers( self, shim: CodexGatewayShim, host: DurableToolHost | None ) -> dict[str, McpServer]: @@ -1229,17 +1283,10 @@ async def _finish_suspend( session = self._require_session() await self._save_attachments() await self._save_results() - path = find_rollout(codex_home, thread_id) - if path is None: - raise UiPathCodexRuntimeError( - UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, - "Cannot suspend the Codex run", - f"No rollout file for thread {thread_id} under {codex_home}.", - UiPathErrorCategory.SYSTEM, - ) - raw = await asyncio.to_thread(path.read_bytes) + path, data = await self._read_rollout( + codex_home, thread_id, "Cannot suspend the Codex run" + ) is_zst = path.name.endswith(".zst") - data = raw if is_zst else cut_torn_tail(raw) pending = { cid: call for cid, call in (await session.get_pending()).items() @@ -1282,9 +1329,7 @@ async def _finish_suspend( f"The rollout does not hold the pending call(s) {missing}.", UiPathErrorCategory.SYSTEM, ) - rel = os.path.relpath(path, codex_home) - await session.set_rollout(encode_rollout(rel, data)) - await session.set_thread(ThreadRecord(thread_id, rel)) + await self._store_rollout(codex_home, thread_id, path, data) await session.set_dangling(dangling) await session.set_completed({}) logger.info( @@ -1294,6 +1339,38 @@ async def _finish_suspend( ) return self._suspended_result(pending) + async def _read_rollout( + self, + codex_home: str, + thread_id: str, + title: str = "Cannot save the Codex conversation", + ) -> tuple[Path, bytes]: + """``(path, content)`` of the thread's rollout, a torn tail cut off.""" + path = find_rollout(codex_home, thread_id) + if path is None: + raise UiPathCodexRuntimeError( + UiPathCodexErrorCode.ROLLOUT_CHECKPOINT_FAILED, + title, + f"No rollout file for thread {thread_id} under {codex_home}.", + UiPathErrorCategory.SYSTEM, + ) + raw = await asyncio.to_thread(path.read_bytes) + return path, raw if path.name.endswith(".zst") else cut_torn_tail(raw) + + async def _store_rollout( + self, codex_home: str, thread_id: str, path: Path, data: bytes + ) -> None: + """Save a rollout read by :meth:`_read_rollout` and its thread record.""" + session = self._require_session() + rel = os.path.relpath(path, codex_home) + await session.set_rollout(encode_rollout(rel, data)) + await session.set_thread(ThreadRecord(thread_id, rel)) + + async def _checkpoint_rollout(self, codex_home: str, thread_id: str) -> None: + """Save the thread's rollout as it stands (after a completed turn).""" + path, data = await self._read_rollout(codex_home, thread_id) + await self._store_rollout(codex_home, thread_id, path, data) + @staticmethod def _suspended_result(pending: dict[str, PendingCall]) -> UiPathRuntimeResult: # Live models, not dicts: a dict would silently become an API trigger. @@ -1798,13 +1875,13 @@ def _require_session(self) -> CodexSessionStore: def _codex_home_dir(self, tools: dict[str, UiPathDurableTool]) -> str | None: """``CODEX_HOME`` to run codex with, or None to leave it as it was. - Durable tools need a run-scoped home (the rollout is saved from and - restored into it). Agents without them keep codex's own resolution + Durable tools and conversational agents need a run-scoped home (the + rollout is saved from and restored into it). Agents without them keep codex's own resolution (``$CODEX_HOME`` or ``~/.codex``) unless ``agent.codex_home`` is set. """ if self.agent.codex_home: path = self.agent.codex_home - elif tools: + elif tools or self._conversational: path = self._codex_home or os.path.join( self._project_dir(), "__uipath", CODEX_HOME_DIR ) @@ -1817,7 +1894,7 @@ def _codex_home_dir(self, tools: dict[str, UiPathDurableTool]) -> str | None: def _hermetic(self, tools: dict[str, UiPathDurableTool]) -> bool: if self.agent.hermetic is not None: return self.agent.hermetic - return bool(tools) + return bool(tools) or self._conversational def _project_dir(self) -> str: return os.path.dirname(os.path.abspath(self.file_path)) or os.getcwd() diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py index a3f5779b..f1cfa09c 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/schema.py @@ -283,8 +283,62 @@ def get_agent_graph(agent: UiPathCodexAgent) -> UiPathRuntimeGraph: return UiPathRuntimeGraph(nodes=nodes, edges=edges) +def _conversation_message_schema() -> dict[str, Any]: + """One UiPath conversation message (camelCase wire form), loosely typed. + + Text parts carry ``data.inline``; a file uploaded in the chat carries + ``data.uri`` (``urn:uipath:cas:file:orchestrator:``) and ``name``. + """ + return { + "type": "object", + "properties": { + "messageId": {"type": "string"}, + "role": {"type": "string"}, + "contentParts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mimeType": {"type": "string"}, + "name": {"type": "string"}, + "data": { + "type": "object", + "properties": { + "inline": {}, + "uri": {"type": "string"}, + }, + }, + }, + }, + }, + "toolCalls": {"type": "array", "items": {"type": "object"}}, + }, + } + + +def default_messages_schema() -> dict[str, Any]: + """Input and output schema of a conversational agent. + + Nothing is required, as in uipath-claude-sdk: the chat host feeds the + messages rather than a form, and the reply goes over the chat bridge + rather than into the run's output. + """ + return { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": _conversation_message_schema(), + "title": "Messages", + "description": "UiPath conversation messages", + } + }, + "required": [], + } + + def get_entrypoint_schema( - agent: UiPathCodexAgent, entrypoint: str + agent: UiPathCodexAgent, entrypoint: str, *, conversational: bool = False ) -> UiPathRuntimeSchema: """Build the UiPath runtime schema for a Codex agent entrypoint. @@ -301,19 +355,23 @@ def get_entrypoint_schema( uses; the default Codex model (``gpt-5.6-terra``) was checked to answer there, structured output included. An agent on a Responses-only model should set an explicit model on its mocking strategies and judges. + + A ``conversational`` agent's input and output are both + :func:`default_messages_schema`. """ return UiPathRuntimeSchema( filePath=entrypoint, uniqueId=str(uuid4()), type="agent", - input=_input_schema(agent), - output=_output_schema(agent), + input=default_messages_schema() if conversational else _input_schema(agent), + output=default_messages_schema() if conversational else _output_schema(agent), graph=get_agent_graph(agent), metadata={"settings": {"model": agent.model}}, ) __all__ = [ + "default_messages_schema", "get_agent_graph", "get_entrypoint_schema", "prompt_fields", diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py index 58687f2f..23e94924 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/session_store.py @@ -16,7 +16,12 @@ rollout order, with its output type and (for siblings) the output to inject; * ``entrypoint`` – the entrypoint the run started on; * ``trace`` – the job's ``Agent run`` span, left open while the run is - suspended (see :func:`.telemetry.span_record`). + suspended (see :func:`.telemetry.span_record`); +* ``chat`` – a conversational agent's chat state: the messages and tool calls + of the exchange still open in the chat (see :mod:`.chat_events`). + +A conversational agent keeps ``thread`` and ``rollout`` across exchanges: the +conversation is the Codex thread, checkpointed after every exchange. """ from __future__ import annotations @@ -45,6 +50,7 @@ _DANGLING_KEY = "dangling" _ENTRYPOINT_KEY = "entrypoint" _TRACE_KEY = "trace" +_CHAT_KEY = "chat" #: A compressed rollout above this size is stored, with a warning. ROLLOUT_WARN_BYTES = 8 * 1024 * 1024 @@ -491,6 +497,15 @@ async def get_trace(self) -> dict[str, Any] | None: async def set_trace(self, record: dict[str, Any] | None) -> None: await self._set(_TRACE_KEY, record) + # chat ------------------------------------------------------------------ + async def get_chat(self) -> dict[str, Any]: + """The chat state of a conversational agent (empty when none).""" + raw = await self._get(_CHAT_KEY) + return raw if isinstance(raw, dict) else {} + + async def set_chat(self, state: dict[str, Any] | None) -> None: + await self._set(_CHAT_KEY, state or None) + # rollout --------------------------------------------------------------- async def get_rollout(self) -> RolloutRecord | None: return RolloutRecord.from_stored(await self._get(_ROLLOUT_KEY)) @@ -568,6 +583,7 @@ async def clear_all(self) -> None: await self._set(_ROLLOUT_KEY, None) await self._set(_ENTRYPOINT_KEY, None) await self._set(_TRACE_KEY, None) + await self._set(_CHAT_KEY, None) __all__ = [ diff --git a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py index c8f84503..afd60f1e 100644 --- a/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py +++ b/packages/uipath-codex-sdk/src/uipath_codex_sdk/runtime/telemetry.py @@ -3,7 +3,8 @@ A job is one ``Agent run`` span with the low-code agent span schema, so a coded Codex agent's trace reads like any other agent's, however often it suspends: -* ``Agent run - `` (``agentRun``) +* ``Agent run - `` (``agentRun``); for a conversational agent, + one ``Conversational agent run - `` per chat exchange * ``LLM call`` (``llmCall``): one per ``/responses`` call through the gateway shim. The shim sends the trace context upstream, and the LLM Gateway adds @@ -166,24 +167,40 @@ def start_agent_run( output_schema: Any, resumed: bool, attachments: str | None = None, + is_conversational: bool = False, + extra: dict[str, Any] | None = None, ) -> Span: + """Open the ``Agent run`` span. + + A conversational exchange is named as low-code names it, + ``Conversational agent run - ``, and carries neither + ``userPrompt`` (its ``input`` holds the messages) nor ``resumed``, as + low-code's has neither. ``extra`` adds attributes (``conversationId``, + ``exchangeId``...). + """ + span_name = ( + f"Conversational agent run - {agent_name}" + if is_conversational + else f"Agent run - {name}" + ) return get_tracer().start_span( - f"Agent run - {name}", + span_name, attributes=span_attributes( SPAN_AGENT_RUN, agentName=agent_name, source="runtime", - isConversational=False, + isConversational=is_conversational, runtimeKind="coded", framework="codex_sdk", model=model, systemPrompt=system_prompt or "", - userPrompt=user_prompt or "", + userPrompt=None if is_conversational else (user_prompt or ""), input=input if input is not None else {}, inputSchema=input_schema, outputSchema=output_schema, - resumed=resumed, + resumed=None if is_conversational else resumed, attachments=attachments, + **(extra or {}), ), ) diff --git a/packages/uipath-codex-sdk/tests/contract/mock_responses.py b/packages/uipath-codex-sdk/tests/contract/mock_responses.py index 883b3589..ee240090 100644 --- a/packages/uipath-codex-sdk/tests/contract/mock_responses.py +++ b/packages/uipath-codex-sdk/tests/contract/mock_responses.py @@ -3,6 +3,10 @@ Codex is registered against it via ``model_providers.mock.*`` config overrides. Every request body is recorded so tests can assert on exactly what the model would have seen. Responses are scripted by a callable supplied by the test. + +With ``deltas=True`` an assistant message streams as ``response.output_text.delta`` +events (between its ``output_item.added`` and ``output_item.done``), as the LLM +Gateway streams it; Codex turns them into ``item/agentMessage/delta``. """ from __future__ import annotations @@ -107,9 +111,22 @@ class RecordedRequest: headers: dict[str, str] = field(default_factory=dict) +def split_text(text: str, size: int = 3) -> list[str]: + """``text`` in word-boundary chunks of about ``size`` words (as deltas).""" + words = text.split(" ") + return [ + " ".join(words[i : i + size]) + (" " if i + size < len(words) else "") + for i in range(0, len(words), size) + ] or [""] + + class MockResponsesServer: - def __init__(self, script: Script | None = None) -> None: + def __init__(self, script: Script | None = None, deltas: bool = False) -> None: self.script: Script | None = script + #: Stream assistant messages as text deltas. + self.deltas = deltas + #: The deltas sent, one list per streamed message. + self.sent_deltas: list[list[str]] = [] self.requests: list[RecordedRequest] = [] self.other_requests: list[tuple[str, str]] = [] self._lock = threading.Lock() @@ -171,6 +188,9 @@ async def _responses(self, request: web.Request) -> web.StreamResponse: {"type": "response.created", "response": {"id": rid}}, ] for i, item in enumerate(items): + if self.deltas and item.get("type") == "message": + events.extend(self._streamed_message(i, item)) + continue events.append( {"type": "response.output_item.added", "output_index": i, "item": item} ) @@ -204,6 +224,52 @@ async def _responses(self, request: web.Request) -> web.StreamResponse: await resp.write_eof() return resp + def _streamed_message(self, index: int, item: JsonObj) -> list[JsonObj]: + text = "".join( + p.get("text", "") for p in item.get("content") or [] if isinstance(p, dict) + ) + chunks = split_text(text) + with self._lock: + self.sent_deltas.append(chunks) + empty = {**item, "status": "in_progress", "content": []} + events: list[JsonObj] = [ + { + "type": "response.output_item.added", + "output_index": index, + "item": empty, + }, + { + "type": "response.content_part.added", + "item_id": item["id"], + "output_index": index, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": []}, + }, + ] + for chunk in chunks: + events.append( + { + "type": "response.output_text.delta", + "item_id": item["id"], + "output_index": index, + "content_index": 0, + "delta": chunk, + } + ) + events.append( + { + "type": "response.output_text.done", + "item_id": item["id"], + "output_index": index, + "content_index": 0, + "text": text, + } + ) + events.append( + {"type": "response.output_item.done", "output_index": index, "item": item} + ) + return events + # -- helpers for tests (thread-safe) ----------------------------------- def snapshot(self) -> list[RecordedRequest]: with self._lock: @@ -254,6 +320,7 @@ def summarize_input(body: JsonObj, max_text: int = 160) -> list[JsonObj]: "function_call_item", "message_item", "find_tool", + "split_text", "input_items", "last_user_text", "summarize_input", diff --git a/packages/uipath-codex-sdk/tests/durable/test_chat_e2e.py b/packages/uipath-codex-sdk/tests/durable/test_chat_e2e.py new file mode 100644 index 00000000..67c2ac68 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/durable/test_chat_e2e.py @@ -0,0 +1,858 @@ +"""Offline end-to-end tests of conversational (chat) agents. + +Real factory → ``UiPathCodexResumableRuntime`` → ``UiPathCodexChatRuntime`` → +bundled codex 0.161 → gateway shim (mock model, streaming text deltas) + tool +host, with fake Orchestrator jobs. A "new job" gets a fresh runtime dir with +only ``state.db`` copied over, as on another robot. +""" + +from __future__ import annotations + +import asyncio +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest +from uipath.core.chat import UiPathConversationMessageEvent +from uipath.core.triggers import UiPathResumeTriggerName +from uipath.runtime import ( + UiPathRuntimeContext, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeMessageEvent + +from uipath_codex_sdk.runtime.chat_runtime import UiPathCodexChatRuntime +from uipath_codex_sdk.runtime.factory import UiPathCodexRuntimeFactory +from uipath_codex_sdk.runtime.runtime import CONTINUE_PROMPT +from uipath_codex_sdk.runtime.session_store import CodexSessionStore +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage + +from .._typing import as_result, attrs, sqlite_storage +from ..contract.mock_responses import ( + advertised_tools, + find_tool, + function_call_item, + input_items, + message_item, +) +from .fakes import TRIGGER +from .test_attachments_e2e import files # noqa: F401 (fixture) + +pytestmark = pytest.mark.contract + +CONVERSATION = "conv-0001" + +CHAT_MAIN = """ +from pydantic import BaseModel + +from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool + + +class AddIn(BaseModel): + a: int + b: int + + +agent = UiPathCodexAgent( + model="mock-model", + base_instructions="You are a helpful test assistant.", + tools=[ + uipath_process_tool( + "a_plus_b", + folder_path="Shared/Durable", + description="Adds a and b.", + input_schema=AddIn, + ) + ], + durable_settle_timeout=20.0, +) +""" + + +def user_message(text: str, message_id: str | None = None) -> dict[str, Any]: + message: dict[str, Any] = { + "role": "user", + "contentParts": [{"mimeType": "text/plain", "data": {"inline": text}}], + } + if message_id: + message["messageId"] = message_id + return {"messages": [message]} + + +def user_texts(body: dict[str, Any]) -> list[str]: + """The user messages a model request carries (developer context excluded).""" + out: list[str] = [] + for item in input_items(body): + if item.get("type") != "message" or item.get("role") != "user": + continue + parts = item.get("content") or [] + text = " ".join(p.get("text", "") for p in parts if isinstance(p, dict)) + if not text.lstrip().startswith("<"): # environment context etc. + out.append(text) + return out + + +class ChatScript: + """Mock model: echo the newest user text; call ``a_plus_b`` on TRIGGER. + + After the call's output arrives (the durable resume), answer with it. + """ + + def __init__(self) -> None: + self.call_id = "call_chat000000000000000001" + self.histories: list[list[str]] = [] + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]]: + items = input_items(body) + texts = user_texts(body) + self.histories.append(texts) + outputs = { + it["call_id"]: it.get("output") + for it in items + if it.get("type") == "function_call_output" + } + called = any( + it.get("type") == "function_call" and it.get("call_id") == self.call_id + for it in items + ) + last = texts[-1] if texts else "" + if TRIGGER in last and not called: + name, ns = find_tool(advertised_tools(body), "a_plus_b") + assert name, "a_plus_b is not advertised" + return [ + message_item("Let me add them up.", idx), + function_call_item(self.call_id, name, {"a": 17, "b": 25}, ns), + ] + if self.call_id in outputs and last == CONTINUE_PROMPT: + return [message_item(f"The sum is {outputs[self.call_id]}.", idx)] + return [message_item(f"You said: {last}", idx)] + + +@pytest.fixture +def chat_project(tmp_path: Path, monkeypatch) -> Path: + proj = tmp_path / "chat" + proj.mkdir() + (proj / "codex.json").write_text('{"agents": {"agent": "main.py:agent"}}') + (proj / "main.py").write_text(CHAT_MAIN) + (proj / "uipath.json").write_text( + json.dumps({"runtimeOptions": {"isConversational": True}}) + ) + monkeypatch.chdir(proj) + return proj + + +def _context(run_dir: Path, *, resume: bool) -> UiPathRuntimeContext: + return UiPathRuntimeContext( + runtime_dir=str(run_dir), state_file="state.db", resume=resume + ) + + +async def _exchange( + run_dir: Path, input: Any, *, resume: bool +) -> tuple[list[Any], UiPathRuntimeResult, list[Any]]: + factory = UiPathCodexRuntimeFactory(_context(run_dir, resume=resume)) + runtime = await factory.new_runtime("agent", CONVERSATION) + events: list[Any] = [] + try: + async for event in runtime.stream(input, UiPathStreamOptions(resume=resume)): + events.append(event) + storage = await sqlite_storage(factory) + triggers = await storage.get_triggers(CONVERSATION) or [] + finally: + await runtime.dispose() + await factory.dispose() + result = events[-1] + assert isinstance(result, UiPathRuntimeResult), result + return events[:-1], result, triggers + + +def _new_job(previous: Path, root: Path, n: int) -> Path: + """A fresh runtime dir holding only the previous job's state.db.""" + run_dir = root / f"job{n}" + run_dir.mkdir() + shutil.copy(previous / "state.db", run_dir / "state.db") + return run_dir + + +def chat_payloads(events: list[Any]) -> list[dict[str, Any]]: + out = [] + for event in events: + if isinstance(event, UiPathRuntimeMessageEvent): + assert isinstance(event.payload, UiPathConversationMessageEvent), event + out.append(event.payload.model_dump(by_alias=True, exclude_none=True)) + return out + + +def shape(payload: dict[str, Any]) -> str: + """A compact name of a chat event (``startMessage+startContentPart``...).""" + keys: list[str] = [k for k in ("startMessage", "endMessage") if k in payload] + part = payload.get("contentPart") or {} + keys += [k for k in ("startContentPart", "chunk", "endContentPart") if k in part] + call = payload.get("toolCall") or {} + keys += [ + k for k in ("startToolCall", "executingToolCall", "endToolCall") if k in call + ] + return "+".join(keys) + + +def chunks(payloads: list[dict[str, Any]]) -> list[str]: + return [ + p["contentPart"]["chunk"]["data"] + for p in payloads + if "chunk" in (p.get("contentPart") or {}) + ] + + +async def test_three_exchanges_remember_across_jobs( + chat_project, upstream, world, tmp_path +): + upstream.deltas = True + script = ChatScript() + upstream.script = script + jobs = tmp_path / "jobs" + jobs.mkdir() + run_dir = jobs / "job0" + + texts = ["My name is Ada.", "I live in Cluj.", "What do you know about me?"] + seen_payloads: list[list[dict[str, Any]]] = [] + for n, text in enumerate(texts): + if n: + run_dir = _new_job(run_dir, jobs, n) + events, result, triggers = await _exchange( + run_dir, user_message(text, f"m{n}"), resume=bool(n) + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert result.output is None and not result.triggers and not triggers + payloads = chat_payloads(events) + seen_payloads.append(payloads) + assert [shape(p) for p in payloads] == ( + ["startMessage+startContentPart"] + + ["chunk"] * len(upstream.sent_deltas[-1]) + + ["endContentPart", "endMessage"] + ), [shape(p) for p in payloads] + assert chunks(payloads) == upstream.sent_deltas[-1] + assert "".join(chunks(payloads)) == f"You said: {text}" + + # The third request carried the first two exchanges: restored history. + assert script.histories[-1] == texts, script.histories + ids = {p["messageId"] for ps in seen_payloads for p in ps} + assert len(ids) == 3 # one assistant message per exchange + + +async def test_durable_call_inside_an_exchange(chat_project, upstream, world, tmp_path): + upstream.deltas = True + script = ChatScript() + upstream.script = script + jobs = tmp_path / "jobs" + jobs.mkdir() + run_dir = jobs / "job0" + + # --- exchange 1 calls the durable tool: the exchange stays open ------- + events, result, triggers = await _exchange( + run_dir, user_message(f"{TRIGGER}: add 17 and 25", "m1"), resume=False + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + [trigger] = triggers + assert trigger.interrupt_id == script.call_id + assert trigger.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert [t.interrupt_id for t in result.triggers or []] == [script.call_id] + payloads = chat_payloads(events) + shapes = [shape(p) for p in payloads] + assert shapes[0] == "startMessage+startContentPart" + assert shapes[-3:] == ["endContentPart", "startToolCall", "executingToolCall"] + assert "endMessage" not in shapes + assert "".join(chunks(payloads)) == "Let me add them up." + message_id = payloads[0]["messageId"] + start = payloads[-2]["toolCall"] + assert payloads[-2]["messageId"] == message_id + assert start["toolCallId"] == script.call_id + assert start["startToolCall"]["toolName"] == "a_plus_b" + assert start["startToolCall"]["input"] == {"a": 17, "b": 25} + n_requests = len(upstream.snapshot()) + + # --- the job is still running: the resume parks again ------------------ + run_dir = _new_job(run_dir, jobs, 1) + events, result, triggers = await _exchange(run_dir, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.interrupt_id for t in triggers] == [script.call_id] + assert chat_payloads(events) == [] + + # --- the job finished: the exchange continues and ends ----------------- + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + run_dir = _new_job(run_dir, jobs, 2) + events, result, triggers = await _exchange(run_dir, None, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED, result + assert not result.triggers and not triggers + payloads = chat_payloads(events) + shapes = [shape(p) for p in payloads] + assert shapes[:2] == ["endToolCall", "endMessage"], shapes + assert payloads[0]["messageId"] == message_id + end = payloads[0]["toolCall"] + assert end["toolCallId"] == script.call_id + assert end["endToolCall"]["output"] == {"sum": 42} + assert end["endToolCall"]["isError"] is False + assert shapes[2] == "startMessage+startContentPart" + assert shapes[-2:] == ["endContentPart", "endMessage"] + assert "".join(chunks(payloads)) == 'The sum is {"sum": 42}.' + assert len(upstream.snapshot()) > n_requests + assert len(world.invocations) == 1 + + # --- the next exchange remembers the durable one --------------------- + run_dir = _new_job(run_dir, jobs, 3) + events, result, _ = await _exchange( + run_dir, user_message("Thanks!", "m2"), resume=True + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert "".join(chunks(chat_payloads(events))) == "You said: Thanks!" + assert script.histories[-1] == [ + f"{TRIGGER}: add 17 and 25", + CONTINUE_PROMPT, + "Thanks!", + ] + last = upstream.snapshot()[-1].body + outputs = [ + it["output"] + for it in input_items(last) + if it.get("type") == "function_call_output" + ] + assert outputs == [json.dumps({"sum": 42})] + + +async def test_message_while_a_durable_call_is_pending_resuspends( + chat_project, upstream, world, tmp_path +): + upstream.deltas = True + script = ChatScript() + upstream.script = script + jobs = tmp_path / "jobs" + jobs.mkdir() + run_dir = jobs / "job0" + _, result, triggers = await _exchange( + run_dir, user_message(f"{TRIGGER}: add 17 and 25", "m1"), resume=False + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED + [trigger] = triggers + n_requests = len(upstream.snapshot()) + + # A new message arrives (CAS resumes the job with it): not processed. + run_dir = _new_job(run_dir, jobs, 1) + events, result, triggers = await _exchange( + run_dir, user_message("Are you there?", "m2"), resume=True + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert chat_payloads(events) == [] + assert len(upstream.snapshot()) == n_requests # no model call + # The trigger is still stored (and returned to the host) for the call. + [kept] = triggers + assert kept.interrupt_id == script.call_id + assert kept.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert kept.item_key == trigger.item_key + assert [t.interrupt_id for t in result.triggers or []] == [script.call_id] + + # The job's original input again (a resumed job may be handed it): it is + # this exchange's own message, so the jobs are read; still running. + run_dir = _new_job(run_dir, jobs, 2) + _, result, triggers = await _exchange( + run_dir, user_message(f"{TRIGGER}: add 17 and 25", "m1"), resume=True + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.interrupt_id for t in triggers] == [script.call_id] + + # A plain-text resume input (``uipath run agent '"hello?"' --resume``). + # ``uipath dev`` never does this: it polls the job and drops what is + # typed meanwhile (``test_uipath_dev_resumes_a_durable_exchange_by_itself``). + run_dir = _new_job(run_dir, jobs, 3) + _, result, triggers = await _exchange(run_dir, "hello?", resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.interrupt_id for t in triggers] == [script.call_id] + assert len(upstream.snapshot()) == n_requests + + # Once the job is done, ``{}`` (``uipath run --resume``: "check the jobs") + # finishes the exchange as usual. + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + run_dir = _new_job(run_dir, jobs, 4) + events, result, triggers = await _exchange(run_dir, {}, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not triggers + payloads = chat_payloads(events) + assert payloads[0]["toolCall"]["endToolCall"]["output"] == {"sum": 42} + assert "".join(chunks(payloads)) == 'The sum is {"sum": 42}.' + # The ignored messages never reached the model. + assert all( + "Are you there?" not in h and "hello?" not in h for h in script.histories + ) + + +async def test_dev_exchanges_share_one_factory_without_resume( + chat_project, upstream, world +): + """``uipath dev``: every exchange is a fresh execute (resume=False) with + the same runtime id, on one factory.""" + script = ChatScript() # no deltas: one chunk per message + upstream.script = script + run_dir = chat_project / "__uipath" + factory = UiPathCodexRuntimeFactory(_context(run_dir, resume=False)) + try: + for text in ["first", "second"]: + runtime = await factory.new_runtime("agent", CONVERSATION) + assert isinstance(runtime.delegate, UiPathCodexChatRuntime) # type: ignore[attr-defined] + events = [ + e + async for e in runtime.stream( + user_message(text), UiPathStreamOptions(resume=False) + ) + ] + await runtime.dispose() + result = as_result(events[-1]) + assert result.status == UiPathRuntimeStatus.SUSPENDED + payloads = chat_payloads(events) + assert [shape(p) for p in payloads] == [ + "startMessage+startContentPart", + "chunk", + "endContentPart", + "endMessage", + ] + assert chunks(payloads) == [f"You said: {text}"] + finally: + await factory.dispose() + assert script.histories[-1] == ["first", "second"] + + +def _cli_uipath_json(proj: Path, exchange_id: str) -> None: + (proj / "uipath.json").write_text( + json.dumps( + { + "runtimeOptions": {"isConversational": True}, + "fpsProperties": { + "conversationalService.conversationId": CONVERSATION, + "conversationalService.exchangeId": exchange_id, + }, + } + ) + ) + + +def _envelopes(log: Path, offset: int) -> list[dict[str, Any]]: + """The CAS envelopes the bridge logged after ``offset``. + + With ``CAS_WEBSOCKET_DISABLED`` it logs each one instead of sending it; + a job's logs go to ``__uipath/execution.log``. + """ + marker = "Not sending event: " + text = log.read_text(encoding="utf-8")[offset:] if log.exists() else "" + return [ + json.loads(line.split(marker, 1)[1]) + for line in text.splitlines() + if marker in line + ] + + +def test_cli_exchanges_reach_the_chat_bridge(cli_env, monkeypatch): + """``uipath run`` as a deployed chat job runs it: a job key, the + conversation ids in ``fpsProperties`` and the bridge in debug mode (it + logs each CAS envelope instead of sending it).""" + from click.testing import CliRunner + from uipath._cli import cli + + proj, server = cli_env + server.deltas = True + server.script = ChatScript() + (proj / "main.py").write_text(CHAT_MAIN) + (proj / "input.json").write_text(json.dumps(user_message("Hello", "m1"))) + (proj / "next.json").write_text(json.dumps(user_message("Again", "m2"))) + monkeypatch.setenv("UIPATH_JOB_KEY", "chat-job-key") + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + monkeypatch.setenv("UIPATH_URL", "https://127.0.0.1:9/org/tenant") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + log = proj / "__uipath" / "execution.log" + + for n, (file, exchange) in enumerate( + [("input.json", "ex-1"), ("next.json", "ex-2")] + ): + _cli_uipath_json(proj, exchange) + offset = len(log.read_text(encoding="utf-8")) if log.exists() else 0 + args = ["run", "agent", "--file", file] + (["--resume"] if n else []) + res = CliRunner().invoke(cli, args, catch_exceptions=False) + assert res.exit_code == 0, res.output + out = json.loads((proj / "__uipath" / "output.json").read_text()) + assert out["status"].lower() == "suspended", out + assert not out.get("resumeTriggers"), out + envelopes = _envelopes(log, offset) + assert envelopes, res.output + assert {e["conversationId"] for e in envelopes} == {CONVERSATION} + assert {e["exchange"]["exchangeId"] for e in envelopes} == {exchange} + assert envelopes[-1]["exchange"] == {"exchangeId": exchange, "endExchange": {}} + messages = [e["exchange"]["message"] for e in envelopes[:-1]] + assert "startMessage" in messages[0] and "endMessage" in messages[-1] + text = "".join( + m["contentPart"]["chunk"]["data"] + for m in messages + if "chunk" in (m.get("contentPart") or {}) + ) + assert text == ("You said: Hello" if n == 0 else "You said: Again") + assert server.script.histories[-1] == ["Hello", "Again"] + + +@pytest.fixture +def spans(): + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + provider = trace.get_tracer_provider() + if not isinstance(provider, TracerProvider): + provider = TracerProvider() + trace.set_tracer_provider(provider) + exporter = InMemorySpanExporter() + trace.get_tracer_provider().add_span_processor( # type: ignore[attr-defined] + SimpleSpanProcessor(exporter) + ) + yield exporter + exporter.shutdown() + + +def _exchange_spans(exporter) -> list[Any]: + return [ + s + for s in exporter.get_finished_spans() + if s.name.startswith("Conversational agent run - ") + ] + + +async def test_each_exchange_is_one_conversational_agent_run( + chat_project, upstream, world, tmp_path, spans +): + script = ChatScript() + upstream.script = script + jobs = tmp_path / "jobs" + jobs.mkdir() + run_dir = jobs / "job0" + + # Two plain exchanges: one finished span each. + for n, text in enumerate(["hello", "again"]): + if n: + run_dir = _new_job(run_dir, jobs, n) + await _exchange(run_dir, user_message(text, f"m{n}"), resume=bool(n)) + plain = _exchange_spans(spans) + assert [s.name for s in plain] == ["Conversational agent run - agent"] * 2 + first, second = (attrs(s) for s in plain) + for a in (first, second): + assert a["isConversational"] is True + assert a["span_type"] == "agentRun" + assert a["conversationId"] == CONVERSATION + assert a["status"] == "successful" + # Not even the second, a job resume in Orchestrator: a new exchange. + assert "userPrompt" not in a and "resumed" not in a + assert first["threadId"] == second["threadId"] + assert json.loads(first["input"]) == user_message("hello", "m0") + assert plain[0].context.span_id != plain[1].context.span_id + + # A durable exchange: the span outlives the suspension, then completes. + run_dir = _new_job(run_dir, jobs, 2) + await _exchange(run_dir, user_message(f"{TRIGGER}: add", "m2"), resume=True) + assert len(_exchange_spans(spans)) == 2 # still open + storage = SqliteResumableStorage(str(run_dir / "state.db")) + try: + saved = await CodexSessionStore(storage, CONVERSATION).get_trace() + finally: + await storage.dispose() + assert saved is not None + assert saved["name"] == "Conversational agent run - agent" + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + run_dir = _new_job(run_dir, jobs, 3) + await _exchange(run_dir, None, resume=True) + done = _exchange_spans(spans) + assert len(done) == 3 + durable = done[-1] + assert format(durable.context.span_id, "016x") == saved["span_id"] + assert attrs(durable)["status"] == "successful" + assert attrs(durable)["threadId"] == first["threadId"] + + +async def test_the_chat_host_keeps_a_parked_exchange_open( + chat_project, upstream, world, tmp_path +): + """Through ``UiPathChatRuntime``: a durable call's trigger is not an API + one, so the exchange is not ended; the resume that finishes it is.""" + from uipath.runtime.chat import UiPathChatRuntime + + from ..test_chat import FakeBridge + + upstream.deltas = True + upstream.script = ChatScript() + jobs = tmp_path / "jobs" + jobs.mkdir() + + async def host_exchange(run_dir: Path, input: Any, resume: bool): + factory = UiPathCodexRuntimeFactory(_context(run_dir, resume=resume)) + bridge = FakeBridge() + runtime = await factory.new_runtime("agent", CONVERSATION) + try: + host = UiPathChatRuntime(delegate=runtime, chat_bridge=bridge) # type: ignore[arg-type] + events = [ + e async for e in host.stream(input, UiPathStreamOptions(resume=resume)) + ] + finally: + await runtime.dispose() + await factory.dispose() + return events[-1], bridge + + result, bridge = await host_exchange( + jobs / "job0", user_message(f"{TRIGGER}: add", "m1"), False + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.trigger_name for t in result.triggers] == [ + UiPathResumeTriggerName.JOB_RAW + ] + assert bridge.exchange_ended == 0 and bridge.interrupts == [] + sent = [m.model_dump(by_alias=True, exclude_none=True) for m in bridge.messages] + assert [shape(p) for p in sent][-2:] == ["startToolCall", "executingToolCall"] + + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + result, bridge = await host_exchange(_new_job(jobs / "job0", jobs, 1), None, True) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert bridge.exchange_ended == 1 and bridge.errors == [] + sent = [m.model_dump(by_alias=True, exclude_none=True) for m in bridge.messages] + assert [shape(p) for p in sent][:2] == ["endToolCall", "endMessage"] + assert [shape(p) for p in sent][-1] == "endMessage" + + +def _dev_service(factory: UiPathCodexRuntimeFactory, shown: list[Any]) -> Any: + """The run service of ``uipath dev``'s web UI, wired as it wires it. + + With the debug bridge it installs, every run is + ``Debug(Chat(Resumable(Codex)))``: the debug runtime polls a durable + call's job trigger and resumes the run itself. + """ + from uipath.core.tracing import UiPathTraceManager + from uipath.dev.server.debug_bridge import WebDebugBridge + from uipath.dev.services.run_service import RunService + + return RunService( + runtime_factory=factory, + trace_manager=UiPathTraceManager(), + on_chat=shown.append, + debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode), + ) + + +def _assistant_texts(run: Any) -> list[str]: + return [ + "".join( + str(p.data.inline) for p in m.content_parts if hasattr(p.data, "inline") + ) + for m in run.chat_events.messages.values() + if m.role == "assistant" + ] + + +async def test_uipath_dev_chat_renders_the_exchanges(chat_project, upstream, world): + """The ``uipath dev`` chat: its run service drives each exchange (a + fresh execute per message, one factory) and folds the events into the + messages its chat panel shows.""" + pytest.importorskip("uipath.dev") + from uipath.dev.models.chat import get_user_message + from uipath.dev.models.execution import ExecutionMode, ExecutionRun + + upstream.deltas = True + upstream.script = ChatScript() + factory = UiPathCodexRuntimeFactory( + _context(chat_project / "__uipath", resume=False) + ) + shown: list[Any] = [] + service = _dev_service(factory, shown) + run = ExecutionRun("agent", {}, ExecutionMode.CHAT) + service.register_run(run) + try: + for text in ["My name is Ada.", "What is my name?"]: + run.input_data = { + "messages": [get_user_message(text).model_dump(by_alias=True)] + } + await service.execute(run) + assert run.status == "completed", (run.status, run.error) + finally: + await factory.dispose() + assistant = [m for m in run.chat_events.messages.values() if m.role == "assistant"] + assert _assistant_texts(run) == [ + "You said: My name is Ada.", + "You said: What is my name?", + ] + assert shown and all( + m.content_parts[0].mime_type == "text/markdown" for m in assistant + ) + assert upstream.script.histories[-1] == ["My name is Ada.", "What is my name?"] + + +async def test_uipath_dev_resumes_a_durable_exchange_by_itself( + chat_project, upstream, world, monkeypatch +): + """In ``uipath dev`` a durable exchange does not show as suspended: the + debug runtime polls the job (every 5 s; faster here) while the run keeps + running, then resumes it once the job is done. The card closes with the + result and the reply continues, with no message sent.""" + pytest.importorskip("uipath.dev") + from uipath.dev.models.chat import get_user_message + from uipath.dev.models.execution import ExecutionMode, ExecutionRun + from uipath.runtime.debug import UiPathDebugRuntime + + polls: list[int] = [] + + async def poll_wait(self: Any) -> None: + polls.append(1) + await asyncio.sleep(0.05) + + monkeypatch.setattr(UiPathDebugRuntime, "_wait_with_quit_check", poll_wait) + upstream.deltas = True + upstream.script = ChatScript() + factory = UiPathCodexRuntimeFactory( + _context(chat_project / "__uipath", resume=False) + ) + service = _dev_service(factory, []) + run = ExecutionRun("agent", {}, ExecutionMode.CHAT) + service.register_run(run) + run.input_data = { + "messages": [ + get_user_message(f"{TRIGGER}: add 17 and 25").model_dump(by_alias=True) + ] + } + task = asyncio.create_task(service.execute(run)) + try: + for _ in range(600): + if polls or task.done(): + break + await asyncio.sleep(0.05) + assert polls, "the debug runtime never polled the job" + # Waiting on the job: still running, not suspended. + assert not task.done() and run.status == "running", run.status + world.finish(world.invocations[0].job_key, "Successful", {"sum": 42}) + await asyncio.wait_for(task, 60) + finally: + if not task.done(): + task.cancel() + await factory.dispose() + assert run.status == "completed", (run.status, run.error) + assert _assistant_texts(run) == ["Let me add them up.", 'The sum is {"sum": 42}.'] + calls = [ + call for m in run.chat_events.messages.values() for call in (m.tool_calls or []) + ] + assert [c.name for c in calls] == ["a_plus_b"] + assert calls[0].result is not None and calls[0].result.output == {"sum": 42} + assert len(world.invocations) == 1 + + +SAMPLE = Path(__file__).resolve().parents[2] / "samples" / "chat-agent" + + +class RoomsScript: + """Mock model for the sample: look the rooms up, then answer from them.""" + + def __init__(self) -> None: + self.histories: list[list[str]] = [] + + def __call__(self, idx: int, body: dict[str, Any]) -> list[dict[str, Any]]: + items = input_items(body) + texts = user_texts(body) + self.histories.append(texts) + outputs = [ + it.get("output") for it in items if it.get("type") == "function_call_output" + ] + if len(texts) == 1 and not outputs: + name, ns = find_tool(advertised_tools(body), "find_rooms") + assert name, "find_rooms is not advertised" + return [ + message_item("Checking the rooms.", idx), + function_call_item("call_rooms01", name, {"people": 6}, ns), + ] + if len(texts) == 1: + return [ + message_item("Beehive seats 8: it fits the Kingfisher review.", idx) + ] + return [message_item("I suggested Beehive, for the Kingfisher review.", idx)] + + +async def test_the_chat_agent_sample(upstream, world, tmp_path, monkeypatch): + proj = tmp_path / "chat-agent" + shutil.copytree( + SAMPLE, proj, ignore=shutil.ignore_patterns("__uipath", "__pycache__", ".venv") + ) + monkeypatch.chdir(proj) + upstream.deltas = True + upstream.script = RoomsScript() + first = json.loads((proj / "input.json").read_text()) + events, result, _ = await _exchange(proj / "__uipath", first, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + payloads = chat_payloads(events) + starts = [ + p["toolCall"] for p in payloads if "startToolCall" in p.get("toolCall", {}) + ] + ends = [p["toolCall"] for p in payloads if "endToolCall" in p.get("toolCall", {})] + assert [s["startToolCall"]["toolName"] for s in starts] == ["find_rooms"] + assert starts[0]["startToolCall"]["input"] == {"people": 6} + rooms = ends[0]["endToolCall"]["output"]["rooms"] + assert [r["name"] for r in rooms] == ["Beehive", "Cedar Hall"] + assert "".join(chunks(payloads)) == ( + "Checking the rooms.Beehive seats 8: it fits the Kingfisher review." + ) + + run_dir = _new_job(proj / "__uipath", tmp_path, 1) + second = json.loads((proj / "next_message.json").read_text()) + events, result, _ = await _exchange(run_dir, second, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert "Beehive" in "".join(chunks(chat_payloads(events))) + assert len(upstream.script.histories[-1]) == 2 + + +async def test_a_file_uploaded_in_the_chat_reaches_analyze_files( + upstream, + files, # noqa: F811 (the fixture imported above) + tmp_path, + monkeypatch, +): + """A chat file (``urn:uipath:cas:file:orchestrator:``) is a job + attachment the run knows: the prompt lists it and the model passes its ID + to Analyze Files (it stays known in later exchanges: ``test_chat``).""" + from .test_attachments_e2e import CALL_ID, DOC_ID, Script + + proj = tmp_path / "chat-agent" + shutil.copytree( + SAMPLE, proj, ignore=shutil.ignore_patterns("__uipath", "__pycache__", ".venv") + ) + monkeypatch.chdir(proj) + script = Script( + "Analyze_Files", + {"attachments": [{"ID": DOC_ID}], "analysisTask": "What is the agenda?"}, + lambda outputs: f"Agenda: {outputs.get(CALL_ID)}", + ) + upstream.script = script + message = { + "messages": [ + { + "role": "user", + "contentParts": [ + {"mimeType": "text/plain", "data": {"inline": "Read this"}}, + { + "mimeType": "application/pdf", + "name": "agenda.pdf", + "data": {"uri": f"urn:uipath:cas:file:orchestrator:{DOC_ID}"}, + }, + ], + } + ] + } + events, result, _ = await _exchange(proj / "__uipath", message, resume=False) + assert result.status == UiPathRuntimeStatus.SUSPENDED + prompt = script.prompts[0] + assert prompt.startswith("Read this\n\nInput attachments (JobAttachment objects):") + assert DOC_ID in prompt and "agenda.pdf" in prompt + payloads = chat_payloads(events) + [end] = [p["toolCall"] for p in payloads if "endToolCall" in p.get("toolCall", {})] + assert end["endToolCall"]["isError"] is False + assert end["endToolCall"]["output"] == {"analysisResult": "Total: 42"} + [request] = files.requests + assert request["input"][0]["content"][1]["filename"] == "agenda-pdf" diff --git a/packages/uipath-codex-sdk/tests/test_chat.py b/packages/uipath-codex-sdk/tests/test_chat.py new file mode 100644 index 00000000..4daeca02 --- /dev/null +++ b/packages/uipath-codex-sdk/tests/test_chat.py @@ -0,0 +1,1306 @@ +"""Conversational (chat) agents: factory switch, input, chat events, exchanges. + +The exchange tests run the real factory, resumable wrapper and chat runtime +over a fake ``AsyncCodex`` (scripted notifications, a rollout file per +thread) and a fake gateway shim; the offline end-to-end tests with the real +codex binary are in ``durable/test_chat_e2e.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from openai_codex.generated.v2_all import ( + AgentMessageDeltaNotification, + ItemCompletedNotification, + ItemStartedNotification, + TurnCompletedNotification, +) +from pydantic import BaseModel +from uipath.core.chat import UiPathConversationMessageEvent +from uipath.core.triggers import UiPathResumeTriggerName +from uipath.platform.common import WaitJobRaw +from uipath.platform.orchestrator import Job +from uipath.platform.resume_triggers import UiPathResumeTriggerHandler +from uipath.runtime import ( + UiPathResumableRuntime, + UiPathRuntimeContext, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, +) +from uipath.runtime.chat import UiPathChatRuntime +from uipath.runtime.events import UiPathRuntimeMessageEvent, UiPathRuntimeStateEvent + +import uipath_codex_sdk.runtime.runtime as runtime_mod +from uipath_codex_sdk import UiPathCodexAgent, uipath_process_tool +from uipath_codex_sdk.runtime.chat_events import ( + ATTACHMENT_ONLY_TEXT, + CodexChatEventMapper, + chat_attachments, + extract_user_message, + is_chat_input, + message_fingerprint, + user_text, +) +from uipath_codex_sdk.runtime.chat_runtime import UiPathCodexChatRuntime +from uipath_codex_sdk.runtime.errors import UiPathCodexRuntimeError +from uipath_codex_sdk.runtime.factory import ( + UiPathCodexResumableRuntime, + UiPathCodexRuntimeFactory, +) +from uipath_codex_sdk.runtime.runtime import UiPathCodexRuntime +from uipath_codex_sdk.runtime.schema import default_messages_schema +from uipath_codex_sdk.runtime.session_store import ( + STATE_STARTED, + CodexSessionStore, + DanglingCall, + EntrypointRecord, + PendingCall, + ThreadRecord, + encode_rollout, +) +from uipath_codex_sdk.runtime.storage import SqliteResumableStorage +from uipath_codex_sdk.runtime.telemetry import start_agent_run + +from ._typing import as_result +from .durable.fakes import FakeOrchestrator + +RT = "conv-1" +FILE_ID = "a940a416-b97b-4146-3089-08de5f4d0a87" + + +def msg(text: str | None = None, *, role: str = "user", **extra: Any) -> dict: + parts = [] + if text is not None: + parts.append({"mimeType": "text/plain", "data": {"inline": text}}) + return {"role": role, "contentParts": parts, **extra} + + +def file_part(name: str = "report.pdf") -> dict: + return { + "mimeType": "application/pdf", + "name": name, + "data": {"uri": f"urn:uipath:cas:file:orchestrator:{FILE_ID}"}, + } + + +def payloads(events: list[Any]) -> list[dict[str, Any]]: + out = [] + for event in events: + if isinstance(event, UiPathRuntimeMessageEvent): + assert isinstance(event.payload, UiPathConversationMessageEvent), event + out.append(event.payload.model_dump(by_alias=True, exclude_none=True)) + return out + + +def shape(payload: dict[str, Any]) -> str: + keys: list[str] = [k for k in ("startMessage", "endMessage") if k in payload] + part = payload.get("contentPart") or {} + keys += [k for k in ("startContentPart", "chunk", "endContentPart") if k in part] + call = payload.get("toolCall") or {} + keys += [ + k for k in ("startToolCall", "executingToolCall", "endToolCall") if k in call + ] + return "+".join(keys) + + +# -------------------------------------------------------------------------- +# factory switch and schema +# -------------------------------------------------------------------------- +def _factory(tmp_path: Path, **context: Any) -> UiPathCodexRuntimeFactory: + return UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(tmp_path / "__uipath"), state_file="state.db", **context + ) + ) + + +def test_conversational_from_uipath_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + factory = _factory(tmp_path) + assert factory._is_conversational() is False # no uipath.json + (tmp_path / "uipath.json").write_text("{not json") + assert factory._is_conversational() is False + (tmp_path / "uipath.json").write_text('{"runtimeOptions": {}}') + assert factory._is_conversational() is False + (tmp_path / "uipath.json").write_text( + '{"runtimeOptions": {"isConversational": true}}' + ) + assert factory._is_conversational() is True + + +def test_conversational_from_the_chat_host(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert _factory(tmp_path, conversation_id="c1")._is_conversational() is True + + +def _project(tmp_path: Path, main: str, conversational: bool = True) -> Path: + (tmp_path / "codex.json").write_text('{"agents": {"agent": "main.py:agent"}}') + (tmp_path / "main.py").write_text(main) + (tmp_path / "uipath.json").write_text( + json.dumps({"runtimeOptions": {"isConversational": conversational}}) + ) + return tmp_path + + +CHAT_MAIN = """ +from uipath_codex_sdk import UiPathCodexAgent + +agent = UiPathCodexAgent(model="m", base_instructions="Be brief.") +""" + + +async def test_factory_builds_the_chat_runtime_with_the_messages_schema( + tmp_path, monkeypatch +): + monkeypatch.chdir(_project(tmp_path, CHAT_MAIN)) + factory = _factory(tmp_path) + try: + runtime = await factory.new_runtime("agent", RT) + assert isinstance(runtime, UiPathCodexResumableRuntime) + assert runtime.conversational + assert isinstance(runtime.delegate, UiPathCodexChatRuntime) + # A run-scoped CODEX_HOME, as for durable agents. + assert runtime.delegate._codex_home is not None + schema = await runtime.get_schema() + assert schema.input == default_messages_schema() == schema.output + assert schema.input["required"] == [] + assert "messages" in schema.input["properties"] + finally: + await factory.dispose() + + +async def test_factory_keeps_the_standard_runtime_otherwise(tmp_path, monkeypatch): + monkeypatch.chdir(_project(tmp_path, CHAT_MAIN, conversational=False)) + factory = _factory(tmp_path) + try: + runtime = await factory.new_runtime("agent", RT) + assert type(runtime.delegate) is UiPathCodexRuntime # type: ignore[attr-defined] + assert not runtime.conversational # type: ignore[attr-defined] + finally: + await factory.dispose() + + +class _In(BaseModel): + topic: str + + +@pytest.mark.parametrize( + "fields, refused", + [ + ({"prompt": "Hi {name}"}, "prompt"), + ({"input_schema": _In}, "input_schema"), + ({"output_schema": _In}, "output_schema"), + ], +) +def test_a_chat_agent_refuses_prompt_and_schemas(tmp_path, fields, refused): + with pytest.raises(UiPathCodexRuntimeError) as info: + UiPathCodexChatRuntime( + agent=UiPathCodexAgent(model="m", **fields), + runtime_id=RT, + entrypoint="agent", + file_path=str(tmp_path / "main.py"), + ) + assert "CONFIG_INVALID" in str(info.value.error_info.code) + assert refused in info.value.error_info.detail + + +# -------------------------------------------------------------------------- +# the chat input +# -------------------------------------------------------------------------- +def test_extract_takes_the_newest_user_message(): + data = { + "messages": [ + msg("first"), + msg("an answer", role="assistant"), + msg("second", messageId="m2"), + msg("from the system", role="system"), + ] + } + message = extract_user_message(data) + assert message is not None and user_text(message) == "second" + assert message_fingerprint(message) == "m2" + # role-less counts as the user's; empty messages are skipped + assert ( + user_text(extract_user_message({"messages": [msg("x", role="")]}) or {}) == "x" + ) + assert extract_user_message({"messages": [msg("x"), msg()]}) is not None + assert extract_user_message({"messages": []}) is None + assert extract_user_message({"messages": [msg(role="assistant")]}) is None + assert extract_user_message(None) is None and extract_user_message("hi") is None + + +def test_extract_joins_text_parts_and_ignores_a_resume_map(): + message = { + "role": "user", + "contentParts": [ + {"mimeType": "text/plain", "data": {"inline": "line one"}}, + {"mimeType": "text/markdown", "data": {"inline": "line two"}}, + {"mimeType": "application/json", "data": {"inline": "{}"}}, + {"data": {"inline": 42}}, + ], + } + found = extract_user_message({"messages": [message]}) + assert found is not None + assert user_text(found) == "line one\nline two" + assert is_chat_input({"messages": []}) + assert not is_chat_input({"c1": {"sum": 1}}) and not is_chat_input(None) + # A durable call's job output may be chat-shaped (a conversational agent + # run as a process): it is still that call's result, not a user message. + assert extract_user_message({"c1": {"messages": [message]}}) is None + assert not is_chat_input({"c1": {"messages": [message]}}) + + +def test_files_become_job_attachments(): + message = msg("Summarize this", contentParts=None) + message["contentParts"] = [ + {"mimeType": "text/plain", "data": {"inline": "Summarize this"}}, + file_part(), + {"mimeType": "image/png", "data": {"uri": "https://elsewhere/x.png"}}, + {"data": {"uri": "urn:uipath:cas:file:orchestrator:not-a-uuid"}}, + ] + [attachment] = chat_attachments(message) + assert str(attachment.id) == FILE_ID + assert attachment.full_name == "report.pdf" + assert attachment.mime_type == "application/pdf" + # A message with only a file still counts. + only_file = {"role": "user", "contentParts": [file_part()]} + assert extract_user_message({"messages": [only_file]}) == only_file + assert message_fingerprint(only_file).startswith("sha256:") + + +# -------------------------------------------------------------------------- +# the mapper +# -------------------------------------------------------------------------- +def _agent_item(item_id: str, text: str = "", phase: str | None = None) -> dict: + return {"type": "agentMessage", "id": item_id, "text": text, "phase": phase} + + +def _mcp_item(item_id: str, status: str = "inProgress", **extra: Any) -> dict: + return { + "type": "mcpToolCall", + "id": item_id, + "server": "rooms", + "tool": "find_rooms", + "arguments": {"people": 6}, + "status": status, + **extra, + } + + +def test_deltas_stream_into_one_message(): + mapper = CodexChatEventMapper() + events = mapper.on_item_started(_agent_item("i1", phase="commentary")) + events += mapper.on_delta("i1", "Hel") + events += mapper.on_delta("i1", "lo") + events += mapper.on_item_completed(_agent_item("i1", "Hello")) + events += mapper.close_turn() + ps = payloads(events) + assert [shape(p) for p in ps] == [ + "startMessage+startContentPart", + "chunk", + "chunk", + "endContentPart", + "endMessage", + ] + mid = ps[0]["messageId"] + assert {p["messageId"] for p in ps} == {mid} + assert ps[0]["startMessage"]["role"] == "assistant" + assert ps[0]["startMessage"]["timestamp"].endswith("Z") + assert ps[0]["contentPart"] == { + "contentPartId": f"chunk-{mid}-0", + "startContentPart": {"mimeType": "text/markdown"}, + } + assert [p["contentPart"]["chunk"]["data"] for p in ps[1:3]] == ["Hel", "lo"] + + +def test_without_deltas_the_whole_text_is_one_chunk(): + mapper = CodexChatEventMapper() + events = mapper.on_item_started(_agent_item("i1")) + events += mapper.on_item_completed(_agent_item("i1", "All at once")) + ps = payloads(events + mapper.close_turn()) + assert [shape(p) for p in ps] == [ + "startMessage+startContentPart", + "chunk", + "endContentPart", + "endMessage", + ] + assert ps[1]["contentPart"]["chunk"]["data"] == "All at once" + # A delta with no item/started opens the message lazily. + mapper = CodexChatEventMapper() + assert shape(payloads(mapper.on_delta("i9", "x"))[0]) == ( + "startMessage+startContentPart" + ) + + +def test_tool_calls_attach_to_the_message_and_end_it(): + mapper = CodexChatEventMapper() + events = mapper.on_item_started(_agent_item("i1")) + events += mapper.on_item_completed(_agent_item("i1", "Looking.")) + events += mapper.on_item_started(_mcp_item("call_1")) + events += mapper.on_item_started( + { + "type": "commandExecution", + "id": "cmd_1", + "command": "ls -la", + "status": "inProgress", + } + ) + events += mapper.on_item_completed( + _mcp_item( + "call_1", + "completed", + result={"content": [{"type": "text", "text": '{"room": "Beehive"}'}]}, + ) + ) + first = payloads(events) + assert "endMessage" not in [shape(p) for p in first] # the command still runs + events = mapper.on_item_completed( + { + "type": "commandExecution", + "id": "cmd_1", + "command": "ls -la", + "status": "failed", + "exit_code": 2, + "aggregated_output": "ls: nope", + } + ) + events += mapper.on_item_started(_agent_item("i2")) + events += mapper.on_item_completed(_agent_item("i2", "Beehive fits.")) + ps = first + payloads(events + mapper.close_turn()) + assert [shape(p) for p in ps] == [ + "startMessage+startContentPart", + "chunk", + "endContentPart", + "startToolCall", + "executingToolCall", + "startToolCall", + "executingToolCall", + "endToolCall", + "endToolCall", + "endMessage", + "startMessage+startContentPart", + "chunk", + "endContentPart", + "endMessage", + ] + m1, m2 = ps[0]["messageId"], ps[-1]["messageId"] + assert m1 != m2 + assert all(p["messageId"] == m1 for p in ps[:10]) + start = ps[3]["toolCall"] + assert start["toolCallId"] == "call_1" + assert start["startToolCall"]["toolName"] == "find_rooms" + assert start["startToolCall"]["input"] == {"people": 6} + assert start["startToolCall"]["metaData"] == {"server": "rooms"} + assert ps[4]["toolCall"]["executingToolCall"]["input"] == {"people": 6} + shell = ps[5]["toolCall"]["startToolCall"] + assert shell["toolName"] == "shell" and shell["input"] == {"command": "ls -la"} + assert ps[7]["toolCall"]["endToolCall"]["output"] == {"room": "Beehive"} + assert ps[7]["toolCall"]["endToolCall"]["isError"] is False + assert ps[8]["toolCall"]["endToolCall"] == { + "timestamp": ps[8]["toolCall"]["endToolCall"]["timestamp"], + "output": "ls: nope", + "isError": True, + } + + +def test_a_tool_call_without_a_message_opens_a_bare_one(): + mapper = CodexChatEventMapper() + events = mapper.on_item_started(_mcp_item("call_1")) + events += mapper.on_item_completed( + _mcp_item("call_1", "failed", error={"message": "boom"}) + ) + ps = payloads(events) + assert [shape(p) for p in ps] == [ + "startMessage", + "startToolCall", + "executingToolCall", + "endToolCall", + "endMessage", + ] + assert ps[3]["toolCall"]["endToolCall"]["output"] == "boom" + assert ps[3]["toolCall"]["endToolCall"]["isError"] is True + assert payloads(mapper.close_turn()) == [] + + +def test_reasoning_and_file_changes_are_not_chat_events(): + mapper = CodexChatEventMapper() + for item in ( + {"type": "reasoning", "id": "r1", "summary": ["x"]}, + {"type": "fileChange", "id": "f1", "changes": [], "status": "completed"}, + {"type": "webSearch", "id": "w1", "query": "q"}, + ): + assert mapper.on_item_started(item) == [] + assert mapper.on_item_completed(item) == [] + + +def test_state_round_trips_and_ends_the_call_on_resume(): + mapper = CodexChatEventMapper() + ps = payloads( + mapper.on_item_started(_agent_item("i1")) + + mapper.on_item_completed(_agent_item("i1", "Starting the job.")) + + mapper.on_item_started( + {**_mcp_item("call_d"), "server": "uipath_tools", "tool": "a_plus_b"} + ) + + mapper.on_item_started(_mcp_item("call_cut")) + ) + mid = ps[0]["messageId"] + # The suspension: a sibling cut off by it is cancelled, the durable kept. + cancelled = payloads(mapper.cancel_calls(keep={"call_d"})) + assert [shape(p) for p in cancelled] == ["endToolCall"] + assert cancelled[0]["toolCall"]["endToolCall"]["cancelled"] is True + state = json.loads(json.dumps(mapper.state())) + assert state["tool_calls"] == {"call_d": mid} + + resumed = CodexChatEventMapper.from_state(state) + ps = payloads( + resumed.on_resumed_call("call_d", "a_plus_b", {}, '{"sum": 42}', False) + ) + assert [shape(p) for p in ps] == ["endToolCall", "endMessage"] + assert {p["messageId"] for p in ps} == {mid} + assert ps[0]["toolCall"]["endToolCall"]["output"] == {"sum": 42} + assert resumed.state() == {"tool_calls": {}, "open_messages": []} + # Nothing saved for a call (an older state): shown whole. + ps = payloads( + CodexChatEventMapper.from_state({}).on_resumed_call( + "c9", "a_plus_b", {"a": 1}, "oops", True + ) + ) + assert [shape(p) for p in ps] == [ + "startMessage", + "startToolCall", + "executingToolCall", + "endToolCall", + "endMessage", + ] + + +# -------------------------------------------------------------------------- +# exchanges over a fake codex +# -------------------------------------------------------------------------- +class FakeTurn: + def __init__(self, thread: FakeThread, prompt: str) -> None: + self.thread = thread + self.prompt = prompt + + async def stream(self): + thread = self.thread + codex = thread.codex + tid, turn = thread.id, f"turn-{len(codex.prompts)}" + base = {"threadId": tid, "turnId": turn} + for spec in codex.script(self.prompt, codex): + kind, data = spec + if kind == "do": # a side effect mid-turn (an awaitable) + await data + continue + if kind == "fail": # the turn fails (codex reports an error) + yield SimpleNamespace( + payload=TurnCompletedNotification.model_validate( + { + "threadId": tid, + "turn": { + "id": turn, + "items": [], + "status": "failed", + "error": {"message": data}, + }, + } + ) + ) + return + if kind == "started": + payload: Any = ItemStartedNotification.model_validate( + {**base, "item": data, "startedAtMs": 0} + ) + elif kind == "completed": + payload = ItemCompletedNotification.model_validate( + {**base, "item": data, "completedAtMs": 0} + ) + else: + payload = AgentMessageDeltaNotification.model_validate( + {**base, "itemId": data[0], "delta": data[1]} + ) + yield SimpleNamespace(payload=payload) + thread.append(self.prompt) + yield SimpleNamespace( + payload=TurnCompletedNotification.model_validate( + { + "threadId": tid, + "turn": {"id": turn, "items": [], "status": "completed"}, + } + ) + ) + + +class FakeThread: + def __init__(self, codex: FakeCodex, thread_id: str) -> None: + self.codex = codex + self.id = thread_id + + def rollout(self) -> Path: + home = Path(self.codex.config.env["CODEX_HOME"]) + found = list(home.glob(f"sessions/**/rollout-*{self.id}.jsonl")) + if found: + return found[0] + path = home / "sessions" / "2026" / "10" / "22" / f"rollout-x-{self.id}.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return path + + def history(self) -> list[str]: + return [ + json.loads(line)["prompt"] + for line in self.rollout().read_text().splitlines() + if line + ] + + def append(self, prompt: str) -> None: + with self.rollout().open("a") as f: + f.write(json.dumps({"prompt": prompt}) + "\n") + + async def turn(self, prompt: str, **kwargs: Any) -> FakeTurn: + self.codex.prompts.append(prompt) + self.codex.histories.append(self.history()) + return FakeTurn(self, prompt) + + +def reply(prompt: str, codex: FakeCodex) -> list[tuple[str, Any]]: + """Default script: one agent message streamed in two deltas.""" + item = f"msg-{len(codex.prompts)}" + text = f"You said: {prompt}" + half = len(text) // 2 + return [ + ("started", _agent_item(item)), + ("delta", (item, text[:half])), + ("delta", (item, text[half:])), + ("completed", _agent_item(item, text, "final_answer")), + ] + + +class FakeCodex: + """Stands in for ``openai_codex.AsyncCodex`` (class-level bookkeeping).""" + + instances: list[FakeCodex] = [] + script = staticmethod(reply) + starts: list[str] = [] + resumes: list[str] = [] + prompts: list[str] = [] + histories: list[list[str]] = [] + + def __init__(self, config: Any) -> None: + self.config = config + self.injected: list[dict[str, Any]] = [] + # ``thread/inject_items`` goes through the raw request of the client. + self._client = SimpleNamespace( + _sync=SimpleNamespace( + _request_raw=lambda method, params: self.injected.append(params) + ) + ) + FakeCodex.instances.append(self) + + async def __aenter__(self) -> FakeCodex: + return self + + async def close(self) -> None: + pass + + async def thread_start(self, **kwargs: Any) -> FakeThread: + thread = FakeThread(self, str(uuid.uuid4())) + FakeCodex.starts.append(thread.id) + thread.rollout() + return thread + + async def thread_resume(self, thread_id: str, **kwargs: Any) -> FakeThread: + FakeCodex.resumes.append(thread_id) + return FakeThread(self, thread_id) + + +class FakeShim: + def __init__(self, **kwargs: Any) -> None: + self.last_activity = 0.0 + self.inflight = 0 + self.mcp_url = "http://127.0.0.1:1/mcp" + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + def env(self) -> dict[str, str]: + return {} + + def config_overrides(self) -> tuple[str, ...]: + return () + + +@pytest.fixture +def codex(monkeypatch): + import openai_codex + + for name in ("instances", "starts", "resumes", "prompts", "histories"): + monkeypatch.setattr(FakeCodex, name, []) + monkeypatch.setattr(FakeCodex, "script", staticmethod(reply)) + monkeypatch.setattr(openai_codex, "AsyncCodex", FakeCodex) + monkeypatch.setattr(runtime_mod, "CodexGatewayShim", FakeShim) + return FakeCodex + + +@pytest.fixture +def chat_dir(tmp_path, monkeypatch) -> Path: + monkeypatch.chdir(_project(tmp_path, CHAT_MAIN)) + return tmp_path + + +async def _exchange( + project: Path, + input: Any, + *, + resume: bool, + run_dir: str = "__uipath", + factory: UiPathCodexRuntimeFactory | None = None, +) -> tuple[list[Any], UiPathRuntimeResult]: + """One exchange; a new factory (a new job) unless one is given (``uipath dev``).""" + own = factory is None + if factory is None: + factory = UiPathCodexRuntimeFactory( + UiPathRuntimeContext( + runtime_dir=str(project / run_dir), state_file="state.db", resume=resume + ) + ) + runtime = await factory.new_runtime("agent", RT) + try: + events = [ + e async for e in runtime.stream(input, UiPathStreamOptions(resume=resume)) + ] + finally: + await runtime.dispose() + if own: + await factory.dispose() + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + return events[:-1], result + + +async def _store(project: Path, run_dir: str = "__uipath"): + storage = SqliteResumableStorage(str(project / run_dir / "state.db")) + return storage, CodexSessionStore(storage, RT) + + +async def test_first_exchange_starts_a_thread_and_suspends_on_nothing(chat_dir, codex): + events, result = await _exchange( + chat_dir, {"messages": [msg("Hi there")]}, resume=False + ) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert result.output is None and not result.triggers + assert len(codex.starts) == 1 and codex.resumes == [] + assert codex.prompts == ["Hi there"] + ps = payloads(events) + assert ( + "".join(p["contentPart"]["chunk"]["data"] for p in ps if shape(p) == "chunk") + == "You said: Hi there" + ) + # No plain-dict message event (the base runtime's) reaches the host. + assert all( + isinstance(e.payload, UiPathConversationMessageEvent) + for e in events + if isinstance(e, UiPathRuntimeMessageEvent) + ) + storage, store = await _store(chat_dir) + try: + thread = await store.get_thread() + assert thread is not None and thread.thread_id == codex.starts[0] + assert thread.rollout_rel is not None + rollout = await store.get_rollout() + assert rollout is not None and b"Hi there" in rollout.content() + assert (await store.get_chat())["user_message"].startswith("sha256:") + finally: + await storage.dispose() + # A run-scoped, hermetic codex home. + config = codex.instances[0].config + assert config.env["CODEX_HOME"] == str(chat_dir / "__uipath" / "codex_home") + assert any("features.plugins=false" in o for o in config.config_overrides) + + +@pytest.mark.parametrize("resume", [False, True], ids=["dev", "orchestrator"]) +async def test_next_exchange_resumes_the_thread_with_the_new_message( + chat_dir, codex, resume, monkeypatch +): + # ``uipath dev`` runs every exchange on one factory, without resume. + dev = _factory(chat_dir) if not resume else None + await _exchange(chat_dir, {"messages": [msg("one")]}, resume=False, factory=dev) + cleared: list[bool] = [] + original = CodexSessionStore.clear_all + + async def clear_all(self): + cleared.append(True) + await original(self) + + monkeypatch.setattr(CodexSessionStore, "clear_all", clear_all) + # The whole history replayed by the host: only the newest message counts. + history = { + "messages": [msg("one"), msg("You said: one", role="assistant"), msg("two")] + } + if resume: # a new job: only state.db carries over + os.makedirs(chat_dir / "job2") + os.replace(chat_dir / "__uipath" / "state.db", chat_dir / "job2" / "state.db") + try: + events, result = await _exchange( + chat_dir, + history, + resume=resume, + run_dir="job2" if resume else "__uipath", + factory=dev, + ) + finally: + if dev is not None: + await dev.dispose() + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.resumes == codex.starts # the same thread + assert codex.prompts == ["one", "two"] + assert codex.histories[-1] == ["one"] # restored rollout + assert cleared == [] + + +async def test_an_exchange_without_a_user_message_is_refused(chat_dir, codex): + with pytest.raises(UiPathCodexRuntimeError) as info: + await _exchange(chat_dir, {"messages": []}, resume=False) + assert "INPUT_INVALID" in str(info.value.error_info.code) + + +async def test_chat_files_reach_the_prompt_and_stay_known(chat_dir, codex): + only_file = {"role": "user", "contentParts": [file_part()]} + await _exchange(chat_dir, {"messages": [only_file]}, resume=False) + prompt = codex.prompts[0] + assert prompt.startswith(ATTACHMENT_ONLY_TEXT) + assert FILE_ID in prompt and "report.pdf" in prompt + await _exchange(chat_dir, {"messages": [msg("And now?")]}, resume=True) + assert codex.prompts[1] == "And now?" + storage = SqliteResumableStorage(str(chat_dir / "__uipath" / "state.db")) + try: + raw = await storage.get_value(RT, "codex_run", "attachments") + finally: + await storage.dispose() + assert [a["ID"] for a in raw["value"]] == [FILE_ID] + + +class FakeBridge: + """A chat bridge that records what the host sends.""" + + def __init__(self) -> None: + self.messages: list[UiPathConversationMessageEvent] = [] + self.exchange_ended = 0 + self.errors: list[Exception] = [] + self.interrupts: list[Any] = [] + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + async def emit_message_event(self, event: Any) -> None: + self.messages.append(event) + + async def emit_interrupt_event(self, trigger: Any) -> None: + self.interrupts.append(trigger) + + async def emit_executing_tool_call_event(self, **kwargs: Any) -> None: + pass + + async def emit_exchange_end_event(self) -> None: + self.exchange_ended += 1 + + async def emit_exchange_error_event(self, error: Exception) -> None: + self.errors.append(error) + + async def wait_for_resume(self) -> dict[str, Any]: # pragma: no cover + raise AssertionError("no API trigger expected") + + +async def test_the_chat_host_gets_message_events_and_the_exchange_end(chat_dir, codex): + def script(prompt: str, codex: FakeCodex) -> list[tuple[str, Any]]: + return [ + ("started", _agent_item("c1", phase="commentary")), + ("completed", _agent_item("c1", "Checking rooms.", "commentary")), + ("started", _mcp_item("call_1")), + ( + "completed", + _mcp_item( + "call_1", + "completed", + result={"content": [{"type": "text", "text": "Beehive"}]}, + ), + ), + ("started", {"type": "reasoning", "id": "r1", "summary": []}), + ("completed", {"type": "reasoning", "id": "r1", "summary": ["think"]}), + *reply(prompt, codex), + ] + + codex.script = staticmethod(script) + factory = _factory(chat_dir) + bridge = FakeBridge() + try: + runtime = await factory.new_runtime("agent", RT) + host = UiPathChatRuntime(delegate=runtime, chat_bridge=bridge) # type: ignore[arg-type] + events = [ + e + async for e in host.stream( + {"messages": [msg("A room for 6?")]}, UiPathStreamOptions() + ) + ] + await runtime.dispose() + finally: + await factory.dispose() + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert bridge.exchange_ended == 1 and bridge.errors == [] + sent = [m.model_dump(by_alias=True, exclude_none=True) for m in bridge.messages] + assert all(isinstance(m, UiPathConversationMessageEvent) for m in bridge.messages) + assert [shape(p) for p in sent] == [ + "startMessage+startContentPart", + "chunk", + "endContentPart", + "startToolCall", + "executingToolCall", + "endToolCall", + "endMessage", + "startMessage+startContentPart", + "chunk", + "chunk", + "endContentPart", + "endMessage", + ] + # Tools and reasoning still reach the debugger as state events. + states = [e for e in events if isinstance(e, UiPathRuntimeStateEvent)] + assert [s.metadata["item_type"] for s in states if s.metadata] == [ + "mcpToolCall", + "reasoning", + ] + + +# -------------------------------------------------------------------------- +# a user message while a durable call is pending +# -------------------------------------------------------------------------- +DURABLE_CHAT = UiPathCodexAgent( + model="m", tools=[uipath_process_tool("a_plus_b")], base_instructions="b" +) + + +@pytest.fixture +def world(monkeypatch) -> FakeOrchestrator: + w = FakeOrchestrator() + import uipath.platform.resume_triggers._protocol as protocol + + import uipath_codex_sdk.tools as tools + + monkeypatch.setattr(tools, "_uipath_client", w.client) + monkeypatch.setattr(protocol, "UiPath", w.client) + monkeypatch.setattr(runtime_mod, "RESUSPEND_BACKOFF_SEC", 0.0) + return w + + +async def _parked(storage, world) -> PendingCall: + """State as left by a chat exchange parked on one durable call.""" + store = CodexSessionStore(storage, RT) + await store.set_entrypoint(EntrypointRecord("agent")) + rel = "sessions/2026/10/22/rollout-x-thread-1.jsonl" + await store.set_thread(ThreadRecord("thread-1", rel)) + await store.set_rollout(encode_rollout(rel, b'{"prompt": "add"}\n')) + job = Job.model_validate({"Id": 1, "Key": "job-1", "State": "Running"}) + world.jobs["job-1"] = job + value = WaitJobRaw(job=job, process_folder_key="fk") + call = PendingCall("c1", "a_plus_b", {"a": 1}, STATE_STARTED, "WaitJobRaw", value) + await store.put_pending(call) + await store.set_dangling([DanglingCall("c1", "function_call", durable=True)]) + await store.set_chat( + { + "tool_calls": {"c1": "m-1"}, + "open_messages": [{"id": "m-1", "text_open": False, "calls": ["c1"]}], + "user_message": "msg-1", + "parked": True, + "exchange_id": "ex-1", + } + ) + return call + + +def _chat_delegate( + storage, tmp_path, exchange_id: str | None = None +) -> UiPathCodexChatRuntime: + return UiPathCodexChatRuntime( + agent=DURABLE_CHAT, + runtime_id=RT, + entrypoint="agent", + file_path=str(tmp_path / "main.py"), + storage=storage, + exchange_id=exchange_id, + ) + + +@pytest.fixture +async def storage(tmp_path): + s = SqliteResumableStorage(str(tmp_path / "state.db")) + yield s + await s.dispose() + + +@pytest.mark.parametrize("wrapper", ["stock", "codex"]) +async def test_a_message_while_a_call_is_pending_resuspends( + storage, world, tmp_path, codex, wrapper +): + """Re-suspended on the same call, its trigger saved again; nothing runs. + + The stock ``UiPathResumableRuntime`` deletes the stored trigger when it + is given explicit input, so the runtime returns the pending call again + and the trigger is minted anew; the codex wrapper keeps it stored. + """ + await _parked(storage, world) + handler = UiPathResumeTriggerHandler() + seed = UiPathResumableRuntime( + delegate=_chat_delegate(storage, tmp_path), + storage=storage, + trigger_manager=handler, + runtime_id=RT, + ) + await seed._handle_suspension( + UiPathRuntimeResult( + output={ + "c1": (await CodexSessionStore(storage, RT).get_pending())[ + "c1" + ].suspend_value() + }, + status=UiPathRuntimeStatus.SUSPENDED, + ) + ) + [before] = await storage.get_triggers(RT) + kwargs: dict[str, Any] = { + "delegate": _chat_delegate(storage, tmp_path), + "storage": storage, + "trigger_manager": handler, + "runtime_id": RT, + } + runtime = ( + UiPathResumableRuntime(**kwargs) + if wrapper == "stock" + else UiPathCodexResumableRuntime(**kwargs, conversational=True) + ) + events = [ + e + async for e in runtime.stream( + {"messages": [msg("Still there?", messageId="msg-2")]}, + UiPathStreamOptions(resume=True), + ) + ] + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert [t.interrupt_id for t in result.triggers or []] == ["c1"] + [after] = await storage.get_triggers(RT) + assert after.interrupt_id == "c1" + assert after.trigger_name == UiPathResumeTriggerName.JOB_RAW + assert after.item_key == before.item_key == "job-1" + assert codex.instances == [] # codex never ran + assert payloads(events) == [] + store = CodexSessionStore(storage, RT) + assert list(await store.get_pending()) == ["c1"] + assert (await store.get_chat())["tool_calls"] == {"c1": "m-1"} + + # The job finishes: the resume ends the call on its message, continues. + world.finish("job-1", "Successful", {"sum": 2}) + events = [e async for e in runtime.stream(None, UiPathStreamOptions(resume=True))] + ps = payloads(events) + assert [shape(p) for p in ps][:2] == ["endToolCall", "endMessage"] + assert {p["messageId"] for p in ps[:2]} == {"m-1"} + assert ps[0]["toolCall"]["endToolCall"]["output"] == {"sum": 2} + assert codex.prompts == [runtime_mod.CONTINUE_PROMPT] + assert codex.histories == [["add"]] # the restored rollout + [injected] = codex.instances[0].injected + assert injected["items"] == [ + {"type": "function_call_output", "call_id": "c1", "output": '{"sum": 2}'} + ] + result = as_result(events[-1]) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + + +async def _seed_trigger(storage, tmp_path) -> None: + """Store the trigger of the parked call, as the resumable wrapper did.""" + seed = UiPathResumableRuntime( + delegate=_chat_delegate(storage, tmp_path), + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id=RT, + ) + pending = await CodexSessionStore(storage, RT).get_pending() + await seed._handle_suspension( + UiPathRuntimeResult( + output={"c1": pending["c1"].suspend_value()}, + status=UiPathRuntimeStatus.SUSPENDED, + ) + ) + + +async def test_a_chat_shaped_call_result_is_no_user_message( + storage, world, tmp_path, codex +): + """A resume value of a durable call that holds chat messages (the output + of a conversational agent run as a process, given as ``{call_id: value}``) + is that call's result: the exchange continues with it instead of taking + it for a new user message and suspending again on the call.""" + await _parked(storage, world) + await _seed_trigger(storage, tmp_path) + child = {"messages": [msg("Hello from the child agent")]} + runtime = UiPathCodexResumableRuntime( + delegate=_chat_delegate(storage, tmp_path), + storage=storage, + trigger_manager=UiPathResumeTriggerHandler(), + runtime_id=RT, + conversational=True, + ) + + async def drain() -> list[Any]: + return [ + e + async for e in runtime.stream( + {"c1": child}, UiPathStreamOptions(resume=True) + ) + ] + + events = await asyncio.wait_for(drain(), 30) + result = as_result(events[-1]) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.prompts == [runtime_mod.CONTINUE_PROMPT] + [injected] = codex.instances[0].injected + [item] = injected["items"] + assert item["call_id"] == "c1" and json.loads(item["output"]) == child + assert payloads(events)[0]["toolCall"]["endToolCall"]["output"] == child + assert await CodexSessionStore(storage, RT).get_pending() == {} + assert not await storage.get_triggers(RT) + + +@pytest.mark.parametrize("first", [True, False], ids=["first", "later"]) +async def test_an_exchange_that_faulted_leaves_nothing_parked( + chat_dir, codex, first, caplog +): + """The pending call of a job an exchange started before it faulted is not + waited on: the next message reaches the model (``uipath dev``: one + factory, the state kept).""" + factory = _factory(chat_dir) + try: + if not first: + await _exchange( + chat_dir, {"messages": [msg("one")]}, resume=False, factory=factory + ) + store = CodexSessionStore(await factory._get_storage(), RT) + job = Job.model_validate({"Id": 1, "Key": "job-1", "State": "Running"}) + orphan = PendingCall( + "c1", + "a_plus_b", + {"a": 1}, + STATE_STARTED, + "WaitJobRaw", + WaitJobRaw(job=job, process_folder_key="fk"), + ) + + def faulting(prompt: str, codex: FakeCodex) -> list[tuple[str, Any]]: + # The job is started (its call saved), then the turn fails. + return [("do", store.put_pending(orphan)), ("fail", "model down")] + + codex.script = staticmethod(faulting) + with pytest.raises(UiPathCodexRuntimeError): + await _exchange( + chat_dir, {"messages": [msg("add")]}, resume=False, factory=factory + ) + assert list(await store.get_pending()) == ["c1"] + codex.script = staticmethod(reply) + events, result = await _exchange( + chat_dir, {"messages": [msg("three")]}, resume=False, factory=factory + ) + assert await store.get_pending() == {} + finally: + await factory.dispose() + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.prompts[-1] == "three" + text = "".join( + p["contentPart"]["chunk"]["data"] + for p in payloads(events) + if shape(p) == "chunk" + ) + assert text == "You said: three" + if first: + # The faulted first exchange saved no conversation: a new thread. + assert len(codex.starts) == 2 and codex.resumes == [] + assert "has no saved conversation" in caplog.text + else: + assert codex.resumes == [codex.starts[0]] * 2 + assert codex.histories[-1] == ["one"] # the last checkpoint + + +async def test_a_thread_without_a_saved_rollout_starts_a_new_one( + chat_dir, codex, caplog +): + os.makedirs(chat_dir / "__uipath") + storage, store = await _store(chat_dir) + try: + await store.set_entrypoint(EntrypointRecord("agent")) + await store.set_thread(ThreadRecord("thread-lost")) + finally: + await storage.dispose() + _, result = await _exchange(chat_dir, {"messages": [msg("hi")]}, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.resumes == [] and len(codex.starts) == 1 + assert codex.prompts == ["hi"] + assert "thread-lost has no saved conversation" in caplog.text + storage, store = await _store(chat_dir) + try: + thread = await store.get_thread() + assert thread is not None and thread.thread_id == codex.starts[0] + finally: + await storage.dispose() + + +async def test_a_new_exchange_clears_a_stale_trigger(chat_dir, codex): + """A trigger left by an earlier suspension would keep the exchange open + (the host waits on it), and the wrapper would hand it back.""" + from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType + + await _exchange(chat_dir, {"messages": [msg("one")]}, resume=False) + storage, _ = await _store(chat_dir) + try: + stale = UiPathResumeTrigger( + interrupt_id="old-call", + trigger_type=UiPathResumeTriggerType.JOB, + trigger_name=UiPathResumeTriggerName.JOB_RAW, + item_key="old-job", + ) + await storage.save_triggers(RT, [stale]) + assert len(await storage.get_triggers(RT) or []) == 1 + finally: + await storage.dispose() + _, result = await _exchange(chat_dir, {"messages": [msg("two")]}, resume=True) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.prompts == ["one", "two"] + storage, _ = await _store(chat_dir) + try: + assert not await storage.get_triggers(RT) + finally: + await storage.dispose() + + +async def test_a_message_after_every_call_finished_is_the_prompt( + storage, world, tmp_path, codex +): + """Parked with the results of every call (none still running): the + results go in and the new message is the prompt. The old message is the + parked exchange's: it ends without chat events in this one.""" + await _parked(storage, world) + store = CodexSessionStore(storage, RT) + await store.remove_pending(["c1"]) + await store.set_completed({"c1": '{"sum": 2}'}) + runtime = _chat_delegate(storage, tmp_path) + events = [ + e + async for e in runtime.stream( + {"messages": [msg("And now?", messageId="msg-2")]}, + UiPathStreamOptions(resume=True), + ) + ] + result = as_result(events[-1]) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.prompts == ["And now?"] + [injected] = codex.instances[0].injected + assert injected["items"] == [ + {"type": "function_call_output", "call_id": "c1", "output": '{"sum": 2}'} + ] + ps = payloads(events) + assert ps and all(p["messageId"] != "m-1" for p in ps) + assert [shape(p) for p in ps][0] == "startMessage+startContentPart" + chat = await store.get_chat() + assert chat["user_message"] == "msg-2" + assert chat["parked"] is False and chat["open_messages"] == [] + assert await store.get_completed() == {} and await store.get_dangling() == [] + + +@pytest.mark.parametrize("exchange", ["ex-1", "ex-2"]) +async def test_the_parked_message_ends_only_in_its_own_exchange( + storage, world, tmp_path, codex, exchange +): + """The chat host sends every event under the exchange it serves: the + parked message's ``endToolCall`` / ``endMessage`` go out only when the + resume serves the exchange that parked (``ex-1``).""" + await _parked(storage, world) + world.finish("job-1", "Successful", {"sum": 2}) + runtime = _chat_delegate(storage, tmp_path, exchange_id=exchange) + events = [e async for e in runtime.stream({}, UiPathStreamOptions(resume=True))] + result = as_result(events[-1]) + assert result.status == UiPathRuntimeStatus.SUSPENDED and not result.triggers + assert codex.prompts == [runtime_mod.CONTINUE_PROMPT] + shapes = [shape(p) for p in payloads(events)] + ids = [p["messageId"] for p in payloads(events)] + if exchange == "ex-1": + assert shapes[:2] == ["endToolCall", "endMessage"] and ids[:2] == ["m-1"] * 2 + assert shapes[2] == "startMessage+startContentPart" + else: + assert "m-1" not in ids + assert shapes[0] == "startMessage+startContentPart" + assert shapes[-1] == "endMessage" + # The durable result is a state event either way. + states = [e for e in events if isinstance(e, UiPathRuntimeStateEvent)] + assert any((s.metadata or {}).get("call_id") == "c1" for s in states) + + +# -------------------------------------------------------------------------- +# tracing +# -------------------------------------------------------------------------- +def test_the_exchange_span_is_named_as_low_code_names_it(): + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + import uipath_codex_sdk.runtime.telemetry as telemetry + + original = telemetry.get_tracer + telemetry.get_tracer = lambda: provider.get_tracer("t") # type: ignore[assignment] + try: + for conversational in (True, False): + start_agent_run( + "agent", + agent_name="Rooms", + model="m", + input={"messages": []}, + system_prompt="s", + user_prompt="hi", + input_schema={}, + output_schema={}, + resumed=True, + is_conversational=conversational, + extra={"conversationId": "c1", "exchangeId": None}, + ).end() + finally: + telemetry.get_tracer = original # type: ignore[assignment] + chat, plain = exporter.get_finished_spans() + assert chat.name == "Conversational agent run - Rooms" + assert plain.name == "Agent run - agent" + assert chat.attributes is not None and plain.attributes is not None + assert chat.attributes["isConversational"] is True + assert chat.attributes["conversationId"] == "c1" + assert "exchangeId" not in chat.attributes and "userPrompt" not in chat.attributes + assert plain.attributes["isConversational"] is False + assert plain.attributes["userPrompt"] == "hi" + # Low-code's agent-run span has no ``resumed``; a plain run keeps it. + assert "resumed" not in chat.attributes + assert plain.attributes["resumed"] is True diff --git a/packages/uipath-codex-sdk/uv.lock b/packages/uipath-codex-sdk/uv.lock index 9ab9d6ce..6050dfe2 100644 --- a/packages/uipath-codex-sdk/uv.lock +++ b/packages/uipath-codex-sdk/uv.lock @@ -3,7 +3,9 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", ] [[package]] @@ -197,6 +199,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -322,6 +333,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "chardet" version = "7.6.0" @@ -648,6 +757,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -657,6 +835,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "fastapi" +version = "0.159.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -793,6 +988,118 @@ 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 = "httpcore2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, +] + +[[package]] +name = "httptools" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/85/1b1e9e6f2f769dc48610f5e71b9a7d50d5a9532985fbd1f1a8b579f62b0e/httptools-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fd73d0bbf700a30dd87e4412adf41cfa71542a533d6b390c7244bbb8a1152bb", size = 119077, upload-time = "2026-10-09T19:54:10.012Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/72d0caf79e54eb1356527c870daac40f8d06f86cd078fdf73c6bf3f7d100/httptools-0.9.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d2b095129b9a98eb46a271ee9631089529c4e40354576b4aa74e24de9d2bf2f7", size = 114529, upload-time = "2026-10-09T19:54:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/6498fe8218db1ed5f785010c303bfef50516d0988e64988bb8f9f59d70ab/httptools-0.9.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b68fb053b37c258a473ab67f4965c3b439500dc160fe364667035a6833eaf50a", size = 499139, upload-time = "2026-10-09T19:54:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a9/81795025aa1ac0ca5346917571756c3e77ae3d0aa11d70fee11b5f89f713/httptools-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2780e33a58a93f27cc3bb74a55bae6f9a8278a1dbabdff392940d30d381671", size = 498141, upload-time = "2026-10-09T19:54:14.725Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e9/f9070a752f6efb42381c0c63fec08385bfbc6d63be15191c424f6d740575/httptools-0.9.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:272db0c51e8b71e953c1f2ecbe63402b819680e4564be2ef285cfd4584ee8355", size = 524457, upload-time = "2026-10-09T19:54:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/0a/29/201ca4636ebe7cb2c931d6545ed4be0acd5fb90f7351ea0458b5ab7319ec/httptools-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22ab1b10b06d357f01092e60f5e6856a0d479ed79b0ec2166a339ea26c699be2", size = 455418, upload-time = "2026-10-09T19:54:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/24/97/2cc1ad7a28243e35002dcd9c4dd98074bc47c9a0a72be12de01fff10e2a5/httptools-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a59c749a73fbdbc8e63b895a3079825fa085d752e75bc0a500042cb8a801e48", size = 483407, upload-time = "2026-10-09T19:54:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/88/98/c7ca6a34d92010561eb5af95bf0d2b667ce4ea3e83ff7fda68da52e037bf/httptools-0.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6ac1414556b910a879c108d79736f77e797871f9919ed0d2c3cf8cf3ecca986", size = 506748, upload-time = "2026-10-09T19:54:21.886Z" }, + { url = "https://files.pythonhosted.org/packages/ef/62/6aec88e4d1da59005184f1038f5abfaad6143499fbf4baa46c2fed8e5b59/httptools-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:13873eb8aef5972fcfee614f63d47064312ad4efbfe65ade15b8a3b77f8c8659", size = 450253, upload-time = "2026-10-09T19:54:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/5a/06/4be91efa577ccae9a16694a413bb8a7c30cb0ec2dc972627b64e108517b8/httptools-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5042aa1c7e2b1a24c17dab31d8770b63a5101c9abc25f832c6aef6b201e1ca4f", size = 485588, upload-time = "2026-10-09T19:54:25.592Z" }, + { url = "https://files.pythonhosted.org/packages/10/eb/3224a5e3145b784e7344a370a8cc9a0d448035a913ddece6e4c35067315f/httptools-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a4d1ecad62e83cc65b411ea0125972cf3af98821e8117129947fd1e3a113f8d2", size = 85785, upload-time = "2026-10-09T19:54:28.216Z" }, + { url = "https://files.pythonhosted.org/packages/9c/41/214e2da998e6348eb68fd0883ffa9774c83ab7a5da12d97595aafb07d0ba/httptools-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c4fa57d3c31889722f64bfa785545a5e603a893b6f29ac1a41bfa830abeaefd5", size = 92421, upload-time = "2026-10-09T19:54:29.574Z" }, + { url = "https://files.pythonhosted.org/packages/96/af/d8fc6b8581045899a780018842a3db0365c4b8ca46519a528e9b8bc6095e/httptools-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:ecfeee649184ffd800955068be9a6b579a0f33fc3c98535d685d5779cb59347f", size = 89093, upload-time = "2026-10-09T19:54:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" }, + { url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" }, + { url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" }, + { url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" }, + { url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" }, + { url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" }, + { url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" }, + { url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" }, + { url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" }, + { url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" }, + { url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" }, + { url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" }, + { url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" }, + { url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" }, + { url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" }, + { url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" }, + { url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -808,6 +1115,32 @@ 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 = "httpx2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -939,6 +1272,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[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 = "librt" version = "0.16.0" @@ -1055,6 +1415,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1067,6 +1436,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mcp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1596,6 +2026,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1733,6 +2172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, ] +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + [[package]] name = "pydantic" version = "2.14.0" @@ -1925,6 +2373,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pysignalr" version = "1.3.0" @@ -1990,6 +2452,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/de/07cfd386974c2a26a7bde41f2111be29bbfc92b9ea0bb76694415a4a1a78/python_engineio-4.14.0-py3-none-any.whl", hash = "sha256:9f0fe275fb7d67bfc1a632421adf22949fd4843bd9c458c004b0a89cede302a2", size = 60291, upload-time = "2026-08-30T19:51:59.776Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-socketio" version = "5.17.0" @@ -2003,6 +2474,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/be/44b558c944bc16618483967ecd3424c578705aa33ceee7df8c1e4ab43ea0/python_socketio-5.17.0-py3-none-any.whl", hash = "sha256:b5826fd2f8aa02e11347816349b74ac6b53e8a4f4e4b1cf1388e1aff19b7f3f4", size = 82548, upload-time = "2026-09-14T22:51:01.405Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/73/6bc452ae57820feb8bb6d993ecd91e24643d8f9a05409e8f83c56c852f04/pywinpty-3.1.0.tar.gz", hash = "sha256:14595b68054d61f2f1e90cc2b30be47be201de200ae1851c433d8cded101bce0", size = 16224208, upload-time = "2026-10-15T17:31:05.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/d78b094428f10f3365dc9ef48c32e0bb336b2e03c8ab3f786ac5c2ccff99/pywinpty-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:7ed683c7e4f31d92084d062478bf4aa9066a983c92700cb6ac6e4d5ca218723c", size = 2108312, upload-time = "2026-10-15T17:32:12.202Z" }, + { url = "https://files.pythonhosted.org/packages/57/dd/fd3617b3af749ba66227d280e637474f49ce8acbceb87a7ee8e55858bd8b/pywinpty-3.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6cf46443732493e78efb130ac525df117e49db493eee1fc98ba9db70be410c96", size = 832952, upload-time = "2026-10-15T17:35:01.463Z" }, + { url = "https://files.pythonhosted.org/packages/4f/89/ac7ce4b13b3131c1344c3af4053d70219e92d4c8e35b7182a9365432f7a3/pywinpty-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:df249a2d942cbeef71f736119e96d246ea968fe9723dd25e8e439ba81814f85e", size = 2105451, upload-time = "2026-10-15T17:32:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/db/957d5f4d64239720d01839c86f1c9c52aab0dbd4c25767889904bcda7d63/pywinpty-3.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:31c9928935046abe20475392493b5074b3d966ea85c63cc83e939fb54cd9edfe", size = 830609, upload-time = "2026-10-15T17:31:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/fc29cd8d1db4b17fb83074cb763568728a5f21a80e91a481a967e2482f67/pywinpty-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:cc51bc690f58a969eab7dcc8d070321187ed47d5f076edc3b4a56076b38831f3", size = 2104975, upload-time = "2026-10-15T17:32:02.183Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e8/c6146bf22daaf9a6457902a99caa5da386d1ba3e82622fa2acc8b2d4e921/pywinpty-3.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:3670cd05447e56836301620e92dfdd3ef40408cfb59f2ce6290c66c9986b53ef", size = 830306, upload-time = "2026-10-15T17:32:43.981Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0c049de9e2d7d6b637aa36ee3825e643d5dba0a4890881652a6685bbaead/pywinpty-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:d01178e9391366bc3325b4c49068cfce95d10d416f6ade62140b274741bc11e0", size = 2104626, upload-time = "2026-10-15T17:33:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6a/bafc3af2a864d9b681dc6dfcf46cf5de0b070276c9a0baec30c1201e88be/pywinpty-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:44918ef8bc408fbf52f54db9b8c33370aee0a59cee76b071a4b4f41ad5ed89c8", size = 830193, upload-time = "2026-10-15T17:32:03.924Z" }, + { url = "https://files.pythonhosted.org/packages/16/8c/e3a2921b39cbf9e27c67787996bdd99ba0acdf0d3f4c3a12b71e7e19a482/pywinpty-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08afc428ec8765f2b8b9a9954727e1dafbefcb5912acc65952007779449650a1", size = 2104740, upload-time = "2026-10-15T17:32:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4155005f0879ae4f348567abd175bd68902e8b036e39d86e50a025f7a5b9/pywinpty-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:98f00e50639e9edb740eb6eedd6c0ab82959f9633029db59363d06973d00ac0d", size = 830142, upload-time = "2026-10-15T17:31:49.185Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/26d10fc6c02628fc6052f820bad0a55531ba7d937f0952233bcbaaba85b9/pywinpty-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:188923081eea840c15f37c54a29ad6871b04fddf8fbf1c4acf317d0e27bd9cac", size = 2104930, upload-time = "2026-10-15T17:33:07.069Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f4/1b91263807eaf5a98b4c00b3c144576e3d4bd8e093d40e447013c3745334/pywinpty-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:714e8aa138bc61dcfd26c139d8cb8e7b954ea5c09c98bb014ab9b1f5741e289c", size = 830450, upload-time = "2026-10-15T17:32:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4a/6d9048716dbf969b31ffacbf8a0a0ff944196807957826601cdc995b4afa/pywinpty-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c08a2e5fa7a62f31b5965f0be1b92c67394cd5f75dc0a033d45fadc597f0640d", size = 2105086, upload-time = "2026-10-15T17:38:04.937Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/dece20a98f989c0eb8e35a2300e0357b98a1e8b5e3ea0149fad62ed16c12/pywinpty-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:751d0c727d0e81a246aba722cf0dcb2155fd6bc7b82574b1432f81256157ec73", size = 830443, upload-time = "2026-10-15T17:31:43.136Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2058,6 +2573,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +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/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -2086,6 +2615,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947, upload-time = "2026-10-04T16:28:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770, upload-time = "2026-10-04T16:28:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795, upload-time = "2026-10-04T16:28:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137, upload-time = "2026-10-04T16:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789, upload-time = "2026-10-04T16:29:01.011Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929, upload-time = "2026-10-04T16:29:02.465Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426, upload-time = "2026-10-04T16:29:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497, upload-time = "2026-10-04T16:29:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740, upload-time = "2026-10-04T16:29:06.578Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850, upload-time = "2026-10-04T16:29:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661, upload-time = "2026-10-04T16:29:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530, upload-time = "2026-10-04T16:29:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125, upload-time = "2026-10-04T16:29:12.408Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045, upload-time = "2026-10-04T16:29:13.751Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636, upload-time = "2026-10-04T16:29:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112, upload-time = "2026-10-04T16:31:42.643Z" }, + { url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662, upload-time = "2026-10-04T16:31:44.642Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637, upload-time = "2026-10-04T16:31:46.667Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607, upload-time = "2026-10-04T16:31:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762, upload-time = "2026-10-04T16:31:51.24Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563, upload-time = "2026-10-04T16:31:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842, upload-time = "2026-10-04T16:31:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434, upload-time = "2026-10-04T16:31:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909, upload-time = "2026-10-04T16:31:59.977Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658, upload-time = "2026-10-04T16:32:02.14Z" }, + { url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769, upload-time = "2026-10-04T16:32:04.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719, upload-time = "2026-10-04T16:32:06.834Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, +] + [[package]] name = "ruff" version = "0.16.10" @@ -2111,6 +2775,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] +[[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 = "simple-websocket" version = "1.1.0" @@ -2141,6 +2814,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, +] + +[[package]] +name = "starlette" +version = "1.9.0" +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/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + [[package]] name = "tenacity" version = "9.2.1" @@ -2150,6 +2849,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/26/1ff2b0721ac66a3ec5b1402b333110b352ab0a8724052ac279a7b82d40c4/tenacity-9.2.1-py3-none-any.whl", hash = "sha256:9e56f17539296baab7beabb08b92f6ee3d7be92d8be72d763360677c2ad6580e", size = 32310, upload-time = "2026-10-07T12:13:00.102Z" }, ] +[[package]] +name = "textual" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319, upload-time = "2026-01-30T13:46:39.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" }, +] + [[package]] name = "tqdm" version = "4.70.1" @@ -2171,6 +2887,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "typer" +version = "0.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ea/2e31b67051e91a133189e9c000c222502ddc6969856416de0d095de4c0b0/typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff", size = 123312, upload-time = "2026-10-06T17:24:15.054Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -2245,6 +2976,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, + { name = "uipath-dev" }, ] [package.metadata] @@ -2265,6 +2997,7 @@ dev = [ { name = "pytest", specifier = ">=7.4.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.9.4" }, + { name = "uipath-dev", specifier = ">=0.0.85" }, ] [[package]] @@ -2281,6 +3014,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/15/3aa01f0011515c0508e966eabf6b0949d61d8c2c326cf83f943616733ec3/uipath_core-0.5.33-py3-none-any.whl", hash = "sha256:13c541c9b684e3f7556c830442b9fa48a3a42a36a38a3086195b10eeb4360ffb", size = 55133, upload-time = "2026-10-06T19:39:28.9Z" }, ] +[[package]] +name = "uipath-dev" +version = "0.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "fastapi" }, + { name = "mcp", extra = ["cli"] }, + { name = "pyperclip" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, + { name = "textual" }, + { name = "uipath" }, + { name = "uipath-runtime" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/60/781fd61f3e9d9842f8ce7216213a43d3b5d283b8d5d52ca90485a09867bd/uipath_dev-0.0.86.tar.gz", hash = "sha256:e6ee21893449b3999eff5ff558ee6c3cca84dd81fd94c3457b0124fea7de7b53", size = 8785880, upload-time = "2026-10-08T07:31:38.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/3d/5915b3d7b13a9bb16f3acb73801eac63bf346aa95ed8183a47d0f0db7558/uipath_dev-0.0.86-py3-none-any.whl", hash = "sha256:a8b317459d100b1ef464994f947273b31a00d5cbb9eebc8a9edc6b22375d58aa", size = 972162, upload-time = "2026-10-08T07:31:35.924Z" }, +] + [[package]] name = "uipath-ipc" version = "2.5.3" @@ -2352,6 +3105,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b1/948067eab45d5307f04b34e50eb7bd1f7352aee866fa5f0706b061ddacf0/uvloop-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24c58ae4a83e93a04c504bcc678125e36a0bfc44af928ad69444880c60f187a5", size = 1415276, upload-time = "2026-10-01T03:15:32.634Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6f/ee3ee84c5d27f2f0a47ae8b67a6adeacf9841b193c0e07412a1403586ce2/uvloop-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0efdd55bddbd36bb2fcb842d64c0d5f6407c6958c68088cc25df8c09edc5b5fd", size = 779533, upload-time = "2026-10-01T03:15:34.062Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/b5f69dae3736d96a8753c6ecd32d676ecd212be7ba3252e9c379ad9cc05c/uvloop-0.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fcd721113260ffb5e38bf14a8725b17d431f34209f7d1c7005b667946e630b3", size = 3896377, upload-time = "2026-10-01T03:15:35.816Z" }, + { url = "https://files.pythonhosted.org/packages/16/fd/8cbf6124607863399008ae4b0d2bb50c22ed83526deec28dca08d635eb6d/uvloop-0.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab17b3a8aa754be0de0e397f7b95f13b14e56f077a4c6ae295e3d4afd199b325", size = 3956355, upload-time = "2026-10-01T03:15:37.688Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/b73007866e7198519067a1f1afc343b4973ae924d2b7afcea67c44320a98/uvloop-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:80cac5cb90ed7b9b72a217a1d6982b15b829cdbd0ee6bc19b93e3a9e47fb0ac9", size = 3755618, upload-time = "2026-10-01T03:15:39.27Z" }, + { url = "https://files.pythonhosted.org/packages/3c/28/e50816f1ce38b97b28d62bc4adf7c82c33b7c68fa902e41a39adc8a3d189/uvloop-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93087a845cdfb35753e539354ac9551bdd2ff528c202a98df0ae46e852bcf021", size = 3863192, upload-time = "2026-10-01T03:15:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, + { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, + { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, +] + [[package]] name = "vadersentiment" version = "3.3.2" @@ -2364,6 +3190,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, ] +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, + { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, + { url = "https://files.pythonhosted.org/packages/cd/eb/289fec71eba51d6c3ef3a28c81f7213f4e860084ffae5abc866933c564f0/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:45726c6b5a8d67c8087eb2933c4398948546d04206ef40f629dc4881d65d23d1", size = 396932, upload-time = "2026-09-21T09:08:50.217Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/a3c3f557992a20a5bb24607f0da44d39cf6d6ba3f8f6ed7cb31f6c73371a/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6398d250994baff581147ccba846223076fe6492ca93c0d1b3588fcf5355eb5c", size = 394852, upload-time = "2026-09-21T09:08:51.412Z" }, + { url = "https://files.pythonhosted.org/packages/55/65/d45763a5e1108a65af85a217af2930ebf1081c73fd8169d3f75e586992bb/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:591de392458bca26f6130fea4e667c06b657e873ad334ea06fad5a79f5086cef", size = 451789, upload-time = "2026-09-21T09:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" }, +] + [[package]] name = "websockets" version = "15.0.1"