diff --git a/.claude/agents/prd-task-generator.md b/.claude/agents/prd-task-generator.md index e93ce0f4..17355a6b 100644 --- a/.claude/agents/prd-task-generator.md +++ b/.claude/agents/prd-task-generator.md @@ -12,12 +12,12 @@ You are a senior software engineer with deep expertise in the Microsoft Agent 36 1. **Analyze PRD Context**: Thoroughly review the provided PRD, understanding both explicit requirements and implicit dependencies. Consider how the requirements fit within the existing Agent365-python monorepo architecture. 2. **Align with Project Architecture**: Every task you generate must align with the established patterns in this repository: - - The monorepo workspace structure with 13 interdependent packages + - The monorepo workspace structure with eight packages - The Core + Extensions pattern (core packages are framework-agnostic, extensions add framework-specific integration) - Namespace package conventions (`microsoft_agents_a365.*` for imports) - - OpenTelemetry-based observability patterns + - Observability is external: use `microsoft-opentelemetry` and `docs/observability-migration.md` - MCP (Model Context Protocol) tool integration patterns - - The four core package areas: runtime, notifications, observability, and tooling + - The three core package areas: runtime, notifications, and tooling 3. **Consider Design Documentation**: Reference the detailed architecture in `docs/design.md` and per-package design documents in `libraries//docs/design.md` when breaking down tasks. Ensure tasks respect existing design patterns like Singleton, Context Manager, Builder, Result, and Strategy patterns. @@ -52,7 +52,7 @@ Each task you create should include: 1. **Foundation First**: Start with core interfaces, models, and base classes 2. **Core Implementation**: Build out the main functionality -3. **Extensions**: Add framework-specific integrations (OpenAI, LangChain, etc.) +3. **Extensions**: Add supported tooling framework integrations (OpenAI, Google ADK, etc.) 4. **Polish**: Documentation, examples, edge case handling 5. **Integration**: End-to-end testing and validation @@ -63,7 +63,7 @@ When generating tasks, actively consider: - **Package Placement**: Is this a core package feature or a framework extension? - **Workspace Dependencies**: Which existing packages does this depend on? Use `{ workspace = true }` pattern - **Namespace Consistency**: Ensure imports use `microsoft_agents_a365.*` correctly -- **Observability Integration**: Does this feature need tracing/metrics? If so, include observability tasks +- **External Telemetry**: If needed, integrate the separately maintained distro; do not recreate retired packages - **Testing Strategy**: Balance unit tests (fast, mocked) vs integration tests (require real services) - **CI/CD Impact**: Will this require changes to `.github/workflows/ci.yml`? diff --git a/.claude/agents/prd-writer.md b/.claude/agents/prd-writer.md index c41d0ae0..38338d0a 100644 --- a/.claude/agents/prd-writer.md +++ b/.claude/agents/prd-writer.md @@ -20,10 +20,10 @@ You are a senior software engineering architect with deep expertise in creating - Security and compliance considerations 2. **Contextual Awareness**: You have access to the Agent365 Python SDK codebase context. When creating PRDs, ensure alignment with: - - The monorepo workspace structure (13 interdependent packages) + - The monorepo workspace structure (eight packages) - Existing architectural patterns (namespace packages, core + extensions pattern) - Python 3.11+ standards and type hints - - OpenTelemetry-based observability patterns + - Observability is external: see `docs/observability-migration.md` for `microsoft-opentelemetry` - MCP (Model Context Protocol) integration patterns - Async/await conventions for I/O operations - Required copyright headers and code standards @@ -34,7 +34,7 @@ You are a senior software engineering architect with deep expertise in creating - "Which packages in the monorepo will this feature touch?" - "Does this extend core functionality or require a new framework extension?" - "What are the inputs, outputs, and data transformations?" - - "How should this integrate with existing observability/tooling?" + - "How should this integrate with existing tooling or the external telemetry distro?" - "What are the success metrics and acceptance criteria?" - "Are there any security, performance, or compliance requirements?" - "What error scenarios need to be handled?" diff --git a/.claude/agents/task-implementer.md b/.claude/agents/task-implementer.md index 323a271e..5b7cdd8a 100644 --- a/.claude/agents/task-implementer.md +++ b/.claude/agents/task-implementer.md @@ -137,14 +137,14 @@ When presenting your implementation: You have access to comprehensive project documentation through CLAUDE.md and design documents. Key facts to always remember: - Python version: 3.11+ (3.11 and 3.12 tested) -- This is a uv workspace monorepo with 13 interdependent packages +- This is a uv workspace monorepo with eight packages - Packages use namespace structure: `microsoft_agents_a365.*` -- Core packages (runtime, observability-core, tooling, notifications) are framework-agnostic -- Extension packages add framework-specific integrations (OpenAI, LangChain, Semantic Kernel, Agent Framework) -- OpenTelemetry is used for observability (traces, spans, metrics) +- Runtime, Tooling, and Notifications provide framework-agnostic functionality +- Tooling extensions integrate OpenAI, Semantic Kernel, Agent Framework, Azure AI Foundry, and Google ADK +- Observability is external; follow `docs/observability-migration.md` and do not recreate retired packages - MCP (Model Context Protocol) is used for tool integration - CI/CD runs on main and release/* branches, testing Python 3.11 and 3.12 -- Integration tests require Azure OpenAI credentials +- Remaining tests are offline/mocked; add explicitly marked integration tests if a future feature requires them ## Escalation Strategy diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 7cbeaa11..7f84bb00 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -204,15 +204,15 @@ The following fields are **required** for posting inline comments on the PR: **Example:** ```diff -@@ -45,6 +45,9 @@ def initialize_telemetry(self): - tracer = get_tracer() -+ # Configure span processor -+ span_processor = BatchSpanProcessor(exporter) -+ tracer_provider.add_span_processor(span_processor) - return tracer_provider +@@ -45,6 +45,9 @@ def resolve_endpoint(self): + endpoint = get_endpoint() ++ if endpoint is None: ++ raise ValueError("Missing endpoint") ++ validate_endpoint(endpoint) + return endpoint ``` -For a comment on the `tracer_provider.add_span_processor` line: -- **File**: `libraries/microsoft-agents-a365-observability-core/src/microsoft_agents_a365/observability/core/telemetry.py` +For a comment on the `validate_endpoint` line: +- **File**: `libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/power_platform_api_discovery.py` - **Diff Line**: `48` (line 45 + 3 new lines) - **Diff Side**: `RIGHT` (it's an addition) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9bf457cb..d561ee5f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,6 @@ # This will eventually be replaced by security group * @microsoft/agent365-approvers /.github/ @microsoft/agent365-approvers -/libraries/microsoft-agents-a365-observability-*/ @microsoft/agent365-observability-approvers -/tests/observability/ @microsoft/agent365-observability-approvers /libraries/microsoft-agents-a365-notifications/ @microsoft/agent365-tooling-approvers /libraries/microsoft-agents-a365-tooling*/ @microsoft/agent365-tooling-approvers /tests/tooling/ @microsoft/agent365-tooling-approvers \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b5e8aedf..01e8a9fb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,15 @@ # GitHub Copilot Instructions for Agent365-python +## Package and Release Scope + +This repository contains eight packages for Notifications, Runtime, Tooling, and +five Tooling framework adapters. Observability is retired here; do not reintroduce +its packages, imports, tests, or dependency edges. Follow the +[migration guide](../docs/observability-migration.md) for `microsoft-opentelemetry`. +Preserve historical changelogs. Build into a fresh directory and run +`scripts/verify_release_artifacts.py DIRECTORY --version VERSION` before any +artifact promotion or upload; never reuse old `dist/*` release inputs. + ## Code Review Rules ### Rule 1: Check for "Kairo" Keyword diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d365a7f3..d9012d46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: - id: check_python_sdk name: Check Python SDK changes run: | - if git diff --name-only "${{ steps.git_refs.outputs.base }}" "${{ steps.git_refs.outputs.head }}" -- 'libraries/**' 'setup.py' 'pyproject.toml' 'versioning/**' | grep -q .; then + if git diff --name-only "${{ steps.git_refs.outputs.base }}" "${{ steps.git_refs.outputs.head }}" -- 'libraries/**' 'setup.py' 'pyproject.toml' 'uv.lock' 'versioning/**' 'scripts/verify_release_artifacts.py' '.github/workflows/ci.yml' | grep -q .; then echo "Python SDK changes detected" echo "changed=true" >> $GITHUB_OUTPUT else @@ -106,7 +106,7 @@ jobs: cache-dependency-glob: "./pyproject.toml" - name: Install the project - run: uv lock && uv sync --locked --all-extras --dev + run: uv sync --locked --all-extras --dev - name: Verify centralized version constraints run: uv run --frozen tox -e verify-constraints @@ -119,48 +119,78 @@ jobs: - name: Build package run: | - AGENT365_PYTHON_SDK_PACKAGE_VERSION=${{ needs.version-number.outputs.PACKAGE_VERSION }} uv build --all-packages --wheel + build_dir=$(mktemp -d "$RUNNER_TEMP/agent365-wheels.XXXXXX") + echo "SDK_DIST_DIR=$build_dir" >> "$GITHUB_ENV" + uv build --all-packages --wheel --out-dir "$build_dir" + uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$build_dir" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" env: AGENT365_PYTHON_SDK_PACKAGE_VERSION: ${{ needs.version-number.outputs.PACKAGE_VERSION }} - name: Run unit tests run: uv run --frozen tox -e py3${{ matrix.python-version == '3.11' && '11' || '12' }} - - name: Run integration tests - # Only run integration tests if secrets are available - if: ${{ vars.RUN_INTEGRATION_TESTS == 'true' }} - run: uv run --frozen tox -e integration - env: - AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT: ${{ vars.AZURE_OPENAI_DEPLOYMENT }} - AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }} - ENABLE_OBSERVABILITY: true - # Copy package and samples to drop folder - name: Copy package and samples to drop folder run: | - mkdir -p ~/drop/python-${{ matrix.python-version }}/dist && cp -v dist/*.whl $_ + drop_dir=$(mktemp -d "$RUNNER_TEMP/agent365-drop.XXXXXX") + echo "SDK_DROP_DIR=$drop_dir" >> "$GITHUB_ENV" + mkdir "$drop_dir/dist" + cp -v "$SDK_DIST_DIR"/*.whl "$drop_dir/dist/" # Zip files since upload-artifact does not seem like to like folders - name: Create zip - run: cd ~/drop/python-${{ matrix.python-version }} && zip -r ~/drop/python-${{ matrix.python-version }}.zip . + run: cd "$SDK_DROP_DIR" && zip -r "$SDK_DROP_DIR.zip" . - name: Upload package and samples as artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: python-${{ matrix.python-version }} - path: ~/drop/python-${{ matrix.python-version }}.zip + path: ${{ env.SDK_DROP_DIR }}.zip + + publish: + name: Publish verified packages + needs: [version-number, changed-sdks, python-sdk] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/') && needs.changed-sdks.outputs.python-sdk == 'true' + runs-on: ubuntu-latest + env: + PYTHONPATH: ${{ github.workspace }}/versioning/helper + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up uv and Python + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + version: '0.6.x' + python-version: '3.11' + + - name: Install locked release tools + run: uv sync --locked --all-extras --dev + + - name: Create fresh artifact directory + run: | + download_dir=$(mktemp -d "$RUNNER_TEMP/agent365-download.XXXXXX") + echo "SDK_ARTIFACT_DIR=$download_dir" >> "$GITHUB_ENV" + + - name: Download verified Python 3.11 artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: python-3.11 + path: ${{ env.SDK_ARTIFACT_DIR }} + + - name: Extract release wheels + run: | + publish_dir=$(mktemp -d "$RUNNER_TEMP/agent365-publish.XXXXXX") + unzip -q "$SDK_ARTIFACT_DIR"/*.zip -d "$publish_dir" + echo "SDK_DIST_DIR=$publish_dir/dist" >> "$GITHUB_ENV" - name: Publish - # Only publish when: - # 1. It's a push event (not a PR) - # 2. The branch is a release/* branch - # 3. There are SDK changes - if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release/') && needs.changed-sdks.outputs.python-sdk == 'true' run: | - uv run twine upload --config-file .pypirc -r Agent365 dist/* - continue-on-error: true + : "${TWINE_REPOSITORY_URL:?Set the AGENT365_PACKAGE_REPOSITORY_URL repository variable to the approved upload endpoint}" + uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$SDK_DIST_DIR" --version "${{ needs.version-number.outputs.PACKAGE_VERSION }}" + uv run --frozen --no-sync twine upload --non-interactive --repository-url "$TWINE_REPOSITORY_URL" "$SDK_DIST_DIR"/*.whl env: + TWINE_REPOSITORY_URL: ${{ vars.AGENT365_PACKAGE_REPOSITORY_URL }} TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.AZURE_DEVOPS_TOKEN }} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 69f0f2ab..726c2351 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,14 +4,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Microsoft Agent 365 SDK for Python - Enterprise-grade extensions for building production-ready AI agents for M365, Teams, Copilot Studio, and Webchat. This is a multi-package monorepo organized as a uv workspace with 13 interdependent packages. +Microsoft Agent 365 SDK for Python - Enterprise-grade extensions for building production-ready AI agents for M365, Teams, Copilot Studio, and Webchat. This is a uv workspace with eight packages for Notifications, Runtime, and Tooling. + +**Observability is retired from this repository.** Do not reintroduce its packages, +sources, imports, or release dependencies. Use the separately maintained +`microsoft-opentelemetry` distro; see [migration and owner actions](docs/observability-migration.md). **Python Version**: 3.11+ (3.11 and 3.12 tested in CI) **For detailed architecture and design documentation**, see [docs/design.md](docs/design.md), which includes: - Complete package descriptions and usage examples -- Design patterns (Singleton, Context Manager, Builder, Result, Strategy) -- Data flow diagrams for agent invocation tracing and MCP tool discovery +- Design patterns for operation results and environment-based configuration +- MCP tool discovery and chat-history data flows - Configuration options and environment variables - Per-package design documents in `libraries//docs/design.md` @@ -30,12 +34,26 @@ uv lock && uv sync --locked --all-extras --dev ### Building +Bash (macOS/Linux): + ```bash -# Build all packages (requires version environment variable) -export AGENT365_PYTHON_SDK_PACKAGE_VERSION="0.1.0" # Windows: $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION = "0.1.0" -uv build --all-packages --wheel +# Build all packages into a fresh output directory +export AGENT365_PYTHON_SDK_PACKAGE_VERSION="0.1.0" +export PYTHONPATH="$PWD/versioning/helper" +output="$(mktemp -d)" +uv build --all-packages --wheel --out-dir "$output" +uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$output" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" +``` + +PowerShell (Windows): -# Built wheels are output to dist/ +```powershell +# Build all packages (requires version environment variable) +$env:AGENT365_PYTHON_SDK_PACKAGE_VERSION = "0.1.0" +$env:PYTHONPATH = Join-Path (Get-Location) "versioning\helper" +$output = Join-Path ([System.IO.Path]::GetTempPath()) ("agent365-wheels-" + [guid]::NewGuid()) +uv build --all-packages --wheel --out-dir $output +uv run --frozen --no-sync python scripts\verify_release_artifacts.py $output --version $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION ``` ### Testing @@ -50,16 +68,13 @@ uv run --frozen pytest tests/runtime/test_environment_utils.py -v # Run tests matching pattern uv run --frozen pytest tests/ -k "environment" -v -# Run integration tests (requires secrets/environment variables) -uv run --frozen pytest -m integration -v --tb=short - # Run with coverage report pytest tests/ --cov=libraries --cov-report=html -v ``` **Test markers:** - `unit`: Fast, mocked tests (default) -- `integration`: Slow tests requiring real services/API keys +- `integration`: Reserved for real-service tests; none remain after observability retirement ### Running with tox @@ -73,9 +88,6 @@ uv run tox -e format uv run tox -e py311 uv run tox -e py312 -# Run integration tests (requires env vars) -uv run tox -e integration - # Verify centralized dependency constraints uv run tox -e verify-constraints @@ -111,28 +123,22 @@ uv run --frozen ruff format . ### Package Structure -The repository follows a **monorepo workspace** pattern with 13 packages organized into 4 core areas: +The repository follows a **monorepo workspace** pattern with eight packages in three core areas: ``` libraries/ ├── Core Packages (foundation) │ ├── microsoft-agents-a365-runtime # Core utilities and extensions │ ├── microsoft-agents-a365-notifications # Notification services and models -│ ├── microsoft-agents-a365-observability-core # OpenTelemetry-based tracing │ └── microsoft-agents-a365-tooling # Tool definitions and MCP integration │ └── Framework Extensions (integrate with specific AI frameworks) - ├── Observability Extensions - │ ├── *-observability-extensions-openai - │ ├── *-observability-extensions-langchain - │ ├── *-observability-extensions-semantickernel - │ └── *-observability-extensions-agentframework - │ └── Tooling Extensions ├── *-tooling-extensions-openai ├── *-tooling-extensions-semantickernel ├── *-tooling-extensions-agentframework - └── *-tooling-extensions-azureaifoundry + ├── *-tooling-extensions-azureaifoundry + └── *-tooling-extensions-googleadk ``` ### Key Architectural Patterns @@ -142,8 +148,8 @@ libraries/ - Python imports: `microsoft_agents_a365.*` (underscores) 2. **Core + Extensions Pattern**: - - Core packages (runtime, observability-core, tooling) provide framework-agnostic base functionality - - Extension packages add framework-specific integrations (OpenAI, LangChain, Semantic Kernel, Agent Framework) + - Runtime, Notifications, and Tooling provide framework-agnostic base functionality + - Tooling extensions integrate OpenAI, Semantic Kernel, Agent Framework, Azure AI Foundry, and Google ADK - Extensions depend on core packages but cores are independent 3. **Workspace Dependencies**: @@ -151,12 +157,7 @@ libraries/ - Packages reference each other using `{ workspace = true }` - All packages built and versioned together -4. **Observability**: - - Built on OpenTelemetry (traces, spans, metrics) - - Core provides base functionality; extensions add framework-specific instrumentation - - Azure Monitor and Jaeger export options available - -5. **Tooling**: +4. **Tooling**: - Tool definitions for agent capabilities - MCP (Model Context Protocol) integration - Framework-specific adapters for tool execution @@ -196,9 +197,8 @@ Tests mirror the library structure: ``` tests/ ├── runtime/ # Runtime package tests -├── observability/ # Observability core and extension tests ├── tooling/ # Tooling core and extension tests -└── notifications/ # Notifications package tests +└── test_*.py # Build, metadata, constraints, and release-artifact tests ``` ## Code Standards @@ -219,17 +219,14 @@ Place it before imports with one blank line after. - **Never** use the keyword "Kairo" in code - it's a legacy reference that must be removed/replaced - If found during code review, flag for removal -### Observability Export Configuration — Coordinated Review Required - -The following three constants must stay in sync. If a PR changes **any one** of them, the reviewer (human or Copilot) **must** ask the author to confirm the other two are still correct: - -| Constant | Location | -|---|---| -| `PROD_OBSERVABILITY_SCOPE` | `libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py` | -| `DEFAULT_ENDPOINT_URL` | `libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py` | -| Export URL path pattern | `build_export_url()` in `libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py` | +### Retired Package Release Guard -Snapshot tests in `tests/observability/core/test_export_config_consistency.py` will fail if any value drifts, but the developer must also verify the values are correct for the target environment — the tests only catch accidental drift, not intentional-but-incomplete updates. +Build wheels into a fresh output directory and run `scripts/verify_release_artifacts.py` +before copying, promoting, or uploading them. The guard requires the exact active +workspace package set and version, and rejects retired distributions, dependencies, +and bundled observability source. Never upload a reusable `dist/*` directory. +Keep historical changelog entries unchanged. External release branches, credentials, +and artifact promoters require the owner controls in the migration guide. ### Python Conventions @@ -279,13 +276,14 @@ The `.github/workflows/ci.yml` pipeline: - Runs on pushes to `main` and `release/*` branches - Tests both Python 3.11 and 3.12 - Uses **tox** (via `uv run --frozen tox -e `) to run lint, format, test, and constraint verification steps -- Executes: verify-constraints → lint → format → build → unit tests → integration tests (if secrets available) +- Executes: verify-constraints → lint → format → build → unit tests - Only publishes packages on `release/*` branches when SDK changes detected +- Publication is gated by fresh-wheel verification, not by tag presence - Uses git-based versioning (tags on release branches = official versions, others = dev versions) ## Important Notes - When installing packages for development, use `-e` (editable) installs: `uv pip install -e libraries/microsoft-agents-a365-runtime` - The `AGENT365_PYTHON_SDK_PACKAGE_VERSION` environment variable must be set before building -- Integration tests require Azure OpenAI credentials (secrets not available in PRs from forks) +- All remaining tests are offline/mocked; the retired suite contained the live integration tests - All packages are published to PyPI with prefix `microsoft-agents-a365-*` diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index ee572d02..9507a694 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -6,49 +6,36 @@ This diagram shows the internal dependencies between Microsoft Agent 365 SDK Pyt graph LR %% Package Nodes microsoft_agents_a365_notifications["microsoft-agents-a365-notifications"] - microsoft_agents_a365_observability_core["microsoft-agents-a365-observability-core"] - microsoft_agents_a365_observability_extensions_langchain["microsoft-agents-a365-observability-extensions-langchain"] - microsoft_agents_a365_observability_extensions_openai["microsoft-agents-a365-observability-extensions-openai"] - microsoft_agents_a365_observability_extensions_semantic_kernel["microsoft-agents-a365-observability-extensions-semantic-kernel"] - microsoft_agents_a365_observability_extensions_agent_framework["microsoft-agents-a365-observability-extensions-agent-framework"] microsoft_agents_a365_runtime["microsoft-agents-a365-runtime"] microsoft_agents_a365_tooling["microsoft-agents-a365-tooling"] microsoft_agents_a365_tooling_extensions_azureaifoundry["microsoft-agents-a365-tooling-extensions-azureaifoundry"] microsoft_agents_a365_tooling_extensions_openai["microsoft-agents-a365-tooling-extensions-openai"] microsoft_agents_a365_tooling_extensions_semantickernel["microsoft-agents-a365-tooling-extensions-semantickernel"] microsoft_agents_a365_tooling_extensions_agentframework["microsoft-agents-a365-tooling-extensions-agentframework"] + microsoft_agents_a365_tooling_extensions_googleadk["microsoft-agents-a365-tooling-extensions-googleadk"] %% Dependencies - microsoft_agents_a365_observability_core --> microsoft_agents_a365_runtime - microsoft_agents_a365_observability_extensions_langchain --> microsoft_agents_a365_observability_core - microsoft_agents_a365_observability_extensions_openai --> microsoft_agents_a365_observability_core - microsoft_agents_a365_observability_extensions_semantic_kernel --> microsoft_agents_a365_observability_core - microsoft_agents_a365_observability_extensions_agent_framework --> microsoft_agents_a365_observability_core + microsoft_agents_a365_tooling --> microsoft_agents_a365_runtime microsoft_agents_a365_tooling_extensions_azureaifoundry --> microsoft_agents_a365_tooling microsoft_agents_a365_tooling_extensions_openai --> microsoft_agents_a365_tooling microsoft_agents_a365_tooling_extensions_semantickernel --> microsoft_agents_a365_tooling microsoft_agents_a365_tooling_extensions_agentframework --> microsoft_agents_a365_tooling + microsoft_agents_a365_tooling_extensions_googleadk --> microsoft_agents_a365_tooling %% Styling classDef notifications fill:#ffcdd2,stroke:#c62828,color:#280505,stroke-width:2px class microsoft_agents_a365_notifications notifications classDef runtime fill:#bbdefb,stroke:#1565c0,color:#0d1a26,stroke-width:2px class microsoft_agents_a365_runtime runtime - classDef observability fill:#c8e6c9,stroke:#2e7d32,color:#142a14,stroke-width:2px - class microsoft_agents_a365_observability_core observability - classDef observability_extensions fill:#e8f5e9,stroke:#66bb6a,color:#1f3d1f,stroke-width:2px - class microsoft_agents_a365_observability_extensions_langchain,microsoft_agents_a365_observability_extensions_openai,microsoft_agents_a365_observability_extensions_semantic_kernel,microsoft_agents_a365_observability_extensions_agent_framework observability_extensions classDef tooling fill:#ffe0b2,stroke:#e65100,color:#331a00,stroke-width:2px class microsoft_agents_a365_tooling tooling classDef tooling_extensions fill:#fff3e0,stroke:#fb8c00,color:#4d2600,stroke-width:2px - class microsoft_agents_a365_tooling_extensions_azureaifoundry,microsoft_agents_a365_tooling_extensions_openai,microsoft_agents_a365_tooling_extensions_semantickernel,microsoft_agents_a365_tooling_extensions_agentframework tooling_extensions + class microsoft_agents_a365_tooling_extensions_azureaifoundry,microsoft_agents_a365_tooling_extensions_openai,microsoft_agents_a365_tooling_extensions_semantickernel,microsoft_agents_a365_tooling_extensions_agentframework,microsoft_agents_a365_tooling_extensions_googleadk tooling_extensions ``` ## Package Types - **Notifications** (Red): Notification and messaging extensions - **Runtime** (Blue): Core runtime components -- **Observability** (Green): Telemetry and monitoring core -- **Observability Extensions** (Light Green): Framework-specific observability integrations - **Tooling** (Orange): Agent tooling SDK core - **Tooling Extensions** (Light Orange): Framework-specific tooling integrations diff --git a/HOW_TO_SET_A_VERSION.md b/HOW_TO_SET_A_VERSION.md index f92155cf..71d60534 100644 --- a/HOW_TO_SET_A_VERSION.md +++ b/HOW_TO_SET_A_VERSION.md @@ -2,6 +2,34 @@ This document describes how to manage versioning for the Microsoft Agent 365 Python SDK, whether you're creating development builds or official releases. +**Observability is retired.** Only the eight active workspace distributions may +be released. Follow the [migration and package-owner checklist](docs/observability-migration.md) +for PyPI archival and external/old-branch publishing controls. + +## Mandatory fresh build and artifact verification + +Never upload a reusable `dist/*` directory or an old artifact drop. Each build +below creates a fresh `SDK_DIST_DIR` and verifies the exact wheel set, version, +metadata dependencies, and absence of retired code. Set the version and build +backend path before invoking a build: + +```bash +export PYTHONPATH="$PWD/versioning/helper" +export AGENT365_PYTHON_SDK_PACKAGE_VERSION="$(cd versioning && python -m setuptools_git_versioning)" +``` + +For PowerShell: + +```powershell +$env:PYTHONPATH = Join-Path (Get-Location) "versioning/helper" +Push-Location versioning +$env:AGENT365_PYTHON_SDK_PACKAGE_VERSION = python -m setuptools_git_versioning +Pop-Location +$output = Join-Path ([System.IO.Path]::GetTempPath()) ("agent365-wheels-" + [guid]::NewGuid()) +uv build --all-packages --wheel --out-dir $output +uv run --frozen --no-sync python scripts/verify_release_artifacts.py $output --version $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION +``` + ## Branch Strategy The repository follows a specific branch strategy for releases: @@ -65,8 +93,9 @@ A development version is generated in these scenarios: 2. **Build packages with the dev version:** ```bash - # The version is automatically calculated during build - uv build --all-packages --wheel + SDK_DIST_DIR="$(mktemp -d)" + uv build --all-packages --wheel --out-dir "$SDK_DIST_DIR" + uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$SDK_DIST_DIR" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" ``` 3. **Example output:** @@ -166,7 +195,11 @@ Official releases must be done from a `release/*` branch: git push origin v0.2.0 ``` -> **Note:** The CI pipeline will only publish packages when tags are pushed on `release/*` branches. +> **Important:** The workflow has branch-push triggers, not a tag-push trigger. +> SDK-changing pushes to `release/*` may publish development versions without a +> tag. Tags control version calculation; pushing a tag alone does not start this +> workflow. Coordinate the tagged build/publication through the approved release +> pipeline rather than relying on a tag to gate publishing. #### Step 3: Verify the Release Version @@ -184,7 +217,9 @@ Official releases must be done from a `release/*` branch: 3. **Build packages:** ```bash - uv build --all-packages --wheel + SDK_DIST_DIR="$(mktemp -d)" + uv build --all-packages --wheel --out-dir "$SDK_DIST_DIR" + uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$SDK_DIST_DIR" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" ``` **Expected output:** @@ -195,12 +230,27 @@ Official releases must be done from a `release/*` branch: #### Step 4: Publish to Package Repository -Once the official build is created, the CI pipeline will automatically publish to your package repository when the tag is pushed on a `release/*` branch. +The checked-in workflow verifies and uploads only the fresh wheel directory on +SDK-changing `release/*` branch pushes, using one downstream job after both +Python 3.11 and 3.12 jobs succeed. It downloads the verified Python 3.11 artifact +and re-verifies its wheels immediately before uploading. Before +publishing, owners must set the repository variable +`AGENT365_PACKAGE_REPOSITORY_URL` to the approved package-upload endpoint and +configure the `AZURE_DEVOPS_TOKEN` secret for that feed. The workflow passes the +URL directly to Twine; it does not require a `.pypirc` or default to public PyPI. +A missing URL fails explicitly before any upload. Downstream promotion to PyPI +must be audited separately. Tags alone do not trigger publication. + +For manual releases, first install the locked tools with +`uv sync --locked --all-extras --dev`, then set `TWINE_REPOSITORY_URL` to that same +approved endpoint and supply the appropriate `TWINE_USERNAME` and +`TWINE_PASSWORD` credentials. ```bash -# The CI pipeline does this automatically for release/* branches with tags -# To manually publish (if needed): -uv run twine upload dist/*.whl +# Only for an explicitly approved manual release, after a fresh build: +: "${TWINE_REPOSITORY_URL:?Set the approved package-upload endpoint}" && +uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$SDK_DIST_DIR" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" && +uv run --frozen --no-sync twine upload --non-interactive --repository-url "$TWINE_REPOSITORY_URL" "$SDK_DIST_DIR"/*.whl ``` #### Step 5: Merge Release Branch Back (Optional) @@ -284,7 +334,9 @@ You need to create a hotfix `0.2.1` after `0.2.0` has been released: cd versioning export AGENT365_PYTHON_SDK_PACKAGE_VERSION=$(python -m setuptools_git_versioning) cd .. -uv build --all-packages --wheel +SDK_DIST_DIR="$(mktemp -d)" +uv build --all-packages --wheel --out-dir "$SDK_DIST_DIR" +uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$SDK_DIST_DIR" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" ``` ### Problem: Version doesn't match expectations @@ -359,7 +411,7 @@ git tag -l "v0.2.0" | List release branches | `git branch -r \| grep release/` | | Delete local tag | `git tag -d v0.2.0` | | Delete remote tag | `git push origin :refs/tags/v0.2.0` | -| Build packages | `uv build --all-packages --wheel` | +| Build and verify packages | Follow [the fresh-directory build procedure](#mandatory-fresh-build-and-artifact-verification) | | View tag details | `git show v0.2.0` | | Merge release to main | `git checkout main && git merge release/0.2.0` | @@ -381,4 +433,3 @@ If you encounter issues with versioning: 2. Verify your Git setup with `git log` and `git describe` 3. Check CI logs for version calculation errors 4. Open an issue with version output and Git status - diff --git a/README.md b/README.md index 0e616e8c..d044205e 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ # Microsoft Agent 365 SDK - Python -[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-observability-core?label=PyPI&logo=pypi)](https://pypi.org/search/?q=microsoft-agents-a365) -[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-observability-core?label=Downloads&logo=pypi)](https://pypi.org/search/?q=microsoft-agents-a365) +[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-runtime?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-runtime/) +[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-runtime?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-runtime/) [![CI - Build, Test, and Publish SDKs](https://github.com/microsoft/Agent365-python/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/microsoft/Agent365-python/actions/workflows/ci.yml) [![CodeQL](https://github.com/microsoft/Agent365-python/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main)](https://github.com/microsoft/Agent365-python/actions/workflows/github-code-scanning/codeql) [![License](https://img.shields.io/github/license/microsoft/Agent365-python?label=License)](LICENSE.md) [![Python Version](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python)](https://www.python.org/) [![Contributors](https://img.shields.io/github/contributors/microsoft/Agent365-python?label=Contributors&logo=github)](https://github.com/microsoft/Agent365-python/graphs/contributors) +> **Observability has moved.** This repository no longer builds or releases the Agent365 observability packages. Use [Microsoft OpenTelemetry Distro](https://github.com/microsoft/opentelemetry-distro-python) ([`microsoft-opentelemetry` on PyPI](https://pypi.org/project/microsoft-opentelemetry/)). Existing consumers must follow the **[Observability migration guide](docs/observability-migration.md)**; this is not a drop-in replacement. Notifications, Runtime, and Tooling remain here. + > #### Note: > Use the information in this README to contribute to this open-source project. To learn about using this SDK in your projects, refer to the [Microsoft Agent 365 Developer documentation](https://learn.microsoft.com/microsoft-agent-365/developer/). -The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade capabilities for building sophisticated agents. This SDK provides comprehensive tooling for observability, notifications, runtime utilities, and development tools that help developers create production-ready agents for platforms including M365, Teams, Copilot Studio, and Webchat. +The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade notifications, runtime utilities, and tooling for building production-ready agents for M365, Teams, Copilot Studio, and Webchat. -The Microsoft Agent 365 SDK focuses on four core areas: +The Microsoft Agent 365 SDK in this repository focuses on three core areas: -- **Observability**: Comprehensive tracing, caching, and monitoring capabilities for agent applications - **Notifications**: Agent notification services and models for handling user notifications - **Runtime**: Core utilities and extensions for agent runtime operations - **Tooling**: Developer tools and utilities for building sophisticated agent applications @@ -58,12 +59,29 @@ The best way to consume this SDK is via our PyPI packages found here: [pypi.org] 3. Build the packages: + Bash (macOS/Linux): + ```bash + export AGENT365_PYTHON_SDK_PACKAGE_VERSION="0.1.0" + export PYTHONPATH="$PWD/versioning/helper" + uv sync --locked --all-extras --dev + output="$(mktemp -d)" + uv build --all-packages --wheel --out-dir "$output" + uv run --frozen --no-sync python scripts/verify_release_artifacts.py "$output" --version "$AGENT365_PYTHON_SDK_PACKAGE_VERSION" + ``` + + PowerShell (Windows): + + ```powershell # Set the version - export AGENT365_PYTHON_SDK_PACKAGE_VERSION="0.1.0" # On Windows: $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION = "0.1.0" + $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION = "0.1.0" - # Build all packages - uv build --all-packages --wheel + # Use a new, empty output directory for each build (PowerShell) + $env:PYTHONPATH = Join-Path (Get-Location) "versioning\helper" + uv sync --locked --all-extras --dev + $output = Join-Path ([System.IO.Path]::GetTempPath()) ("agent365-wheels-" + [guid]::NewGuid()) + uv build --all-packages --wheel --out-dir $output + uv run --frozen --no-sync python scripts\verify_release_artifacts.py $output --version $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION ``` 4. Run tests: @@ -75,15 +93,11 @@ The best way to consume this SDK is via our PyPI packages found here: [pypi.org] ## Project Structure - **libraries/microsoft-agents-a365-notifications**: Microsoft Agent 365 Notifications SDK - Agent notification services and models -- **libraries/microsoft-agents-a365-observability-core**: Microsoft Agent 365 Observability Core - Core observability functionality -- **libraries/microsoft-agents-a365-observability-extensions-agentframework**: Agent Framework observability extensions -- **libraries/microsoft-agents-a365-observability-extensions-langchain**: LangChain observability extensions -- **libraries/microsoft-agents-a365-observability-extensions-openai**: OpenAI observability extensions -- **libraries/microsoft-agents-a365-observability-extensions-semantickernel**: Semantic Kernel observability extensions - **libraries/microsoft-agents-a365-runtime**: Microsoft Agent 365 Runtime - Core runtime utilities and extensions - **libraries/microsoft-agents-a365-tooling**: Microsoft Agent 365 Tooling SDK - Agent tooling and MCP integration - **libraries/microsoft-agents-a365-tooling-extensions-agentframework**: Agent Framework tooling extensions - **libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry**: Azure AI Foundry tooling extensions +- **libraries/microsoft-agents-a365-tooling-extensions-googleadk**: Google ADK tooling extensions - **libraries/microsoft-agents-a365-tooling-extensions-openai**: OpenAI tooling extensions - **libraries/microsoft-agents-a365-tooling-extensions-semantickernel**: Semantic Kernel tooling extensions - For sample applications, see the [Microsoft Agent 365 SDK Samples repository](https://github.com/microsoft/Agent365-Samples/tree/main/python) @@ -118,7 +132,7 @@ The core SDK for building conversational AI agents for Microsoft 365 platforms. ### Microsoft Agent 365 SDK -Enterprise-grade extensions for observability, notifications, runtime utilities, and developer tools. +Enterprise-grade extensions for notifications, runtime utilities, and developer tools. Python observability is maintained separately in the [Microsoft OpenTelemetry Distro](https://github.com/microsoft/opentelemetry-distro-python). - [Microsoft Agent 365 SDK - C# /.NET repository](https://github.com/microsoft/Agent365-dotnet) - [Microsoft Agent 365 SDK - Python repository](https://github.com/microsoft/Agent365-python) - You are here diff --git a/libraries/microsoft-agents-a365-observability-core/CHANGELOG.md b/docs/changelogs/observability-core.md similarity index 100% rename from libraries/microsoft-agents-a365-observability-core/CHANGELOG.md rename to docs/changelogs/observability-core.md diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/CHANGELOG.md b/docs/changelogs/observability-extensions-langchain.md similarity index 100% rename from libraries/microsoft-agents-a365-observability-extensions-langchain/CHANGELOG.md rename to docs/changelogs/observability-extensions-langchain.md diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/CHANGELOG.md b/docs/changelogs/observability-extensions-openai.md similarity index 100% rename from libraries/microsoft-agents-a365-observability-extensions-openai/CHANGELOG.md rename to docs/changelogs/observability-extensions-openai.md diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/CHANGELOG.md b/docs/changelogs/observability-extensions-semantickernel.md similarity index 100% rename from libraries/microsoft-agents-a365-observability-extensions-semantickernel/CHANGELOG.md rename to docs/changelogs/observability-extensions-semantickernel.md diff --git a/libraries/microsoft-agents-a365-observability-hosting/CHANGELOG.md b/docs/changelogs/observability-hosting.md similarity index 100% rename from libraries/microsoft-agents-a365-observability-hosting/CHANGELOG.md rename to docs/changelogs/observability-hosting.md diff --git a/docs/design.md b/docs/design.md index f81d2cd8..1b0f2248 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,345 +1,100 @@ # Microsoft Agent 365 SDK for Python - Architecture and Design -This document describes the architecture and design of the Microsoft Agent 365 SDK for Python. It is intended to help developers and coding agents understand the project structure and quickly get started reading, writing, and reviewing code. - -## Overview - -The Microsoft Agent 365 SDK extends the [Microsoft 365 Agents SDK](https://github.com/Microsoft/Agents-for-python) with enterprise-grade capabilities for building sophisticated AI agents. It provides comprehensive tooling for: - -- **Observability**: OpenTelemetry-based tracing, monitoring, and context propagation -- **Notifications**: Agent notification services and activity routing -- **Runtime**: Core utilities for agent operations and Power Platform integration -- **Tooling**: MCP (Model Context Protocol) server configuration and tool discovery - -The SDK supports production deployment across Microsoft 365, Teams, Copilot Studio, and Webchat platforms. - -## Repository Structure - -``` -Agent365-python/ -├── libraries/ # Core packages (13 total) -│ ├── microsoft-agents-a365-notifications/ -│ ├── microsoft-agents-a365-observability-core/ -│ ├── microsoft-agents-a365-observability-extensions-agentframework/ -│ ├── microsoft-agents-a365-observability-extensions-langchain/ -│ ├── microsoft-agents-a365-observability-extensions-openai/ -│ ├── microsoft-agents-a365-observability-extensions-semantickernel/ -│ ├── microsoft-agents-a365-observability-hosting/ -│ ├── microsoft-agents-a365-runtime/ -│ ├── microsoft-agents-a365-tooling/ -│ ├── microsoft-agents-a365-tooling-extensions-agentframework/ -│ ├── microsoft-agents-a365-tooling-extensions-azureaifoundry/ -│ ├── microsoft-agents-a365-tooling-extensions-openai/ -│ └── microsoft-agents-a365-tooling-extensions-semantickernel/ -├── tests/ # Test suite -│ ├── observability/ -│ ├── runtime/ -│ └── tooling/ -├── docs/ # Documentation -├── versioning/ # Version management -└── pyproject.toml # Workspace configuration -``` - -### Package Layout - -Each package follows a consistent structure: - -``` -libraries/microsoft-agents-a365-/ -├── microsoft_agents_a365/ -│ └── / # Source code -│ ├── __init__.py # Public API exports -│ ├── models/ # Data classes -│ ├── services/ # Service implementations -│ └── utils/ # Utilities -├── pyproject.toml # Package configuration -└── README.md # Package documentation -``` - -## Core Packages - -> **Note**: Each package has its own detailed design document in `libraries//docs/design.md`. The sections below provide an overview; refer to the package-specific documents for implementation details. - -### 1. Observability Core (`microsoft-agents-a365-observability-core`) - -> **Detailed documentation**: [libraries/microsoft-agents-a365-observability-core/docs/design.md](../libraries/microsoft-agents-a365-observability-core/docs/design.md) - -The foundation for distributed tracing in agent applications. Built on OpenTelemetry. - -**Key Classes:** - -| Class | Purpose | -|-------|---------| -| `configure()` | Initialize telemetry with service name, namespace, and exporter options | -| `InvokeAgentScope` | Trace agent invocation lifecycle (entry point for agent requests) | -| `InferenceScope` | Trace LLM/AI model inference calls | -| `ExecuteToolScope` | Trace tool execution operations | -| `BaggageBuilder` | Fluent API for context propagation across async boundaries | -| `SpanProcessor` | Copies baggage entries to span attributes | - -**Data Classes:** - -| Class | Purpose | -|-------|---------| -| `InvokeAgentScopeDetails` | Agent endpoint and invocation metadata | -| `AgentDetails` | Agent identification and metadata | -| `UserDetails` | Human caller identification (user ID, email, name, IP) | -| `CallerDetails` | Wrapper for user details and/or caller agent details | -| `SpanDetails` | Parent context, timing, and span kind for custom spans | -| `InferenceCallDetails` | Model name, tokens, provider information | -| `ToolCallDetails` | Tool name, arguments, endpoint | -| `Request` | Content, correlation ID, and conversation ID | - -**Usage Example:** - -```python -from microsoft_agents_a365.observability.core import ( - configure, - InvokeAgentScope, - InvokeAgentScopeDetails, - AgentDetails, - Request, - BaggageBuilder, -) - -# Initialize telemetry -configure( - service_name="my-agent", - service_namespace="my-namespace", - token_resolver=lambda agent_id, tenant_id: get_app_only_obs_token(agent_id, tenant_id), - cluster_category="prod" -) - -# Set context for child spans -with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): - # Trace agent invocation - with InvokeAgentScope.start( - request=Request(content="Hello"), - invoke_scope_details=InvokeAgentScopeDetails(...), - agent_details=AgentDetails(...), - ) as scope: - # Agent logic here - scope.record_response("result") -``` - -### 2. Observability Extensions - -Framework-specific instrumentations that integrate with the observability core: - -| Package | Purpose | Design Doc | -|---------|---------|------------| -| `extensions-openai` | Instrument OpenAI SDK client calls | [design.md](../libraries/microsoft-agents-a365-observability-extensions-openai/docs/design.md) | -| `extensions-langchain` | LangChain callback integration | [design.md](../libraries/microsoft-agents-a365-observability-extensions-langchain/docs/design.md) | -| `extensions-agentframework` | Microsoft Agents SDK integration | [design.md](../libraries/microsoft-agents-a365-observability-extensions-agentframework/docs/design.md) | -| `extensions-semantickernel` | Semantic Kernel instrumentation | [design.md](../libraries/microsoft-agents-a365-observability-extensions-semantickernel/docs/design.md) | -| `hosting` | Hosting-specific observability utilities | — | - -### 3. Runtime (`microsoft-agents-a365-runtime`) - -> **Detailed documentation**: [libraries/microsoft-agents-a365-runtime/docs/design.md](../libraries/microsoft-agents-a365-runtime/docs/design.md) - -Core utilities shared across the SDK. - -**Key Classes:** - -| Class | Purpose | -|-------|---------| -| `Utility` | Token decoding, agent identity resolution, user-agent generation | -| `OperationResult` | Standardized success/failure result pattern (no exceptions) | -| `OperationError` | Error details for failed operations | -| `PowerPlatformApiDiscovery` | Endpoint discovery for different cloud environments | -| `ClusterCategory` | Enum for cluster environments (prod, ppe, test, etc.) | - -**Usage Example:** - -```python -from microsoft_agents_a365.runtime import ( - Utility, - OperationResult, - PowerPlatformApiDiscovery, - ClusterCategory, -) - -# Decode agent identity from JWT token -app_id = Utility.get_app_id_from_token(jwt_token) - -# Discover Power Platform endpoints -discovery = PowerPlatformApiDiscovery() -endpoint = discovery.get_tenant_island_cluster_endpoint( - tenant_id=tenant_id, - cluster_category=ClusterCategory.PROD -) - -# Handle operation results -result = some_operation() -if result.succeeded: - process_success() -else: - for error in result.errors: - log_error(error) -``` - -### 4. Tooling (`microsoft-agents-a365-tooling`) - -> **Detailed documentation**: [libraries/microsoft-agents-a365-tooling/docs/design.md](../libraries/microsoft-agents-a365-tooling/docs/design.md) - -MCP (Model Context Protocol) tool server configuration and discovery. - -**Key Classes:** - -| Class | Purpose | -|-------|---------| -| `McpToolServerConfigurationService` | Discover and configure MCP tool servers | -| `MCPServerConfig` | Tool server configuration (name, endpoint URL) | -| `Constants` | Configuration constants | - -**Dual-Mode Configuration:** - -The tooling package supports two modes based on the `ENVIRONMENT` variable: - -- **Development** (`ENVIRONMENT=Development`): Loads tool servers from a local `ToolingManifest.json` file -- **Production** (default): Discovers tool servers from the Agent365 gateway endpoint - -**Usage Example:** - -```python -from microsoft_agents_a365.tooling import ( - McpToolServerConfigurationService, - MCPServerConfig, -) - -service = McpToolServerConfigurationService() - -# Discover available tool servers -servers = await service.list_tool_servers( - agentic_app_id=app_id, - auth_token=bearer_token -) - -for server in servers: - print(f"Tool: {server.mcp_server_name}") - print(f"Endpoint: {server.mcp_server_unique_name}") -``` - -### 5. Tooling Extensions - -Framework-specific adapters for MCP tool integration: - -| Package | Purpose | Design Doc | -|---------|---------|------------| -| `extensions-agentframework` | Adapt MCP tools to Microsoft Agents SDK | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md) | -| `extensions-azureaifoundry` | Azure AI Foundry tool integration | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/docs/design.md) | -| `extensions-openai` | OpenAI function calling integration and chat history | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-openai/docs/design.md) | -| `extensions-semantickernel` | Semantic Kernel plugin integration | [design.md](../libraries/microsoft-agents-a365-tooling-extensions-semantickernel/docs/design.md) | - -#### OpenAI Extension: Chat History API - -The OpenAI tooling extension provides methods to send chat history to the MCP platform for real-time threat protection: - -**Key Classes:** - -| Class | Purpose | -|-------|---------| -| `McpToolRegistrationService` | MCP tool registration and chat history management | - -**Methods:** - -| Method | Purpose | -|--------|---------| -| `send_chat_history(turn_context, session, limit, options)` | Extract messages from OpenAI Session and send to MCP platform | -| `send_chat_history_messages(turn_context, messages, options)` | Send a list of OpenAI TResponseInputItem messages to MCP platform | - -**Usage Example:** - -```python -from agents import Agent, Runner -from microsoft_agents_a365.tooling.extensions.openai import McpToolRegistrationService - -service = McpToolRegistrationService() -agent = Agent(name="my-agent", model="gpt-4") - -# In your agent handler: -async with Runner.run(agent, messages) as result: - session = result.session - - # Option 1: Send from Session object - op_result = await service.send_chat_history(turn_context, session) - - # Option 2: Send from message list - op_result = await service.send_chat_history_messages(turn_context, messages) - - if op_result.succeeded: - print("Chat history sent successfully") -``` - -The methods convert OpenAI message types to `ChatHistoryMessage` format and delegate to the core `McpToolServerConfigurationService.send_chat_history()` method. - -### 6. Notifications (`microsoft-agents-a365-notifications`) - -> **Detailed documentation**: [libraries/microsoft-agents-a365-notifications/docs/design.md](../libraries/microsoft-agents-a365-notifications/docs/design.md) - -Agent notification and lifecycle event handling. - -**Key Classes:** - -| Class | Purpose | -|-------|---------| -| `AgentNotification` | Main notification handler | -| `AgentHandler` | Base class for notification handlers | -| `AgentNotificationActivity` | Notification activity data | -| `AgentLifecycleEvent` | Agent lifecycle events (start, stop, etc.) | -| `EmailReference` | Email notification metadata | -| `AgentSubChannel` | Sub-channel routing information | -| `NotificationTypes` | Enum of notification types | - -## Design Patterns - -### 1. Singleton Pattern - -`TelemetryManager` uses thread-safe singleton with double-checked locking to ensure a single tracer provider per application: - -```python -# Internal implementation pattern -class TelemetryManager: - _instance = None - _lock = threading.Lock() - - @classmethod - def get_instance(cls): - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = cls() - return cls._instance -``` - -### 2. Context Manager Pattern - -All scope classes implement `__enter__` and `__exit__` for automatic span lifecycle management: - -```python -with InvokeAgentScope.start(...) as scope: - # Span is active - scope.record_response("result") -# Span automatically ends, errors recorded if exception raised -``` - -### 3. Builder Pattern - -`BaggageBuilder` provides a fluent API for context propagation: - -```python -with BaggageBuilder() \ - .tenant_id(tenant_id) \ - .agent_id(agent_id) \ - .correlation_id(correlation_id) \ - .build(): - # Baggage propagated to child spans -``` - -### 4. Result Pattern - -`OperationResult` encapsulates success/failure without exceptions: +This uv workspace contains **eight distributions** for Notifications, Runtime, +Tooling, and five framework-specific Tooling adapters. It extends the +[Microsoft 365 Agents SDK](https://github.com/microsoft/Agents-for-python) for +Microsoft 365, Teams, Copilot Studio, and Webchat applications. + +**Observability is no longer implemented, built, or released here.** Use the +separately maintained [Microsoft OpenTelemetry Distro](https://github.com/microsoft/opentelemetry-distro-python) +and follow the [migration guide](observability-migration.md). Its APIs and +authentication are not drop-in substitutes for the retired SDK. + +## Repository and package layout + +```text +libraries/ + microsoft-agents-a365-notifications/ + microsoft-agents-a365-runtime/ + microsoft-agents-a365-tooling/ + microsoft-agents-a365-tooling-extensions-agentframework/ + microsoft-agents-a365-tooling-extensions-azureaifoundry/ + microsoft-agents-a365-tooling-extensions-googleadk/ + microsoft-agents-a365-tooling-extensions-openai/ + microsoft-agents-a365-tooling-extensions-semantickernel/ +tests/ + runtime/ + tooling/ + test_*.py # Build, constraints, metadata, and release tests +scripts/ # Constraint and release-artifact verification +versioning/helper/ # Shared build backend and version injection +docs/changelogs/ # Preserved retired-package changelogs +``` + +Each library has a `pyproject.toml`, `setup.py`, README, and source under the +shared `microsoft_agents_a365` namespace. Directory/distribution names use dashes; +Python imports use underscores. The root manifest is the authoritative workspace +inventory. See [the generated dependency diagrams](../DEPENDENCIES.md) for all +internal and direct external dependencies. + +## Remaining core packages + +| Distribution | Responsibilities | Detailed design | +|---|---|---| +| `microsoft-agents-a365-runtime` | JWT/agent identity utilities, environment detection, Power Platform endpoint discovery, operation results | [Runtime](../libraries/microsoft-agents-a365-runtime/docs/design.md) | +| `microsoft-agents-a365-notifications` | Notification models, activity routing, agent lifecycle events and handlers | [Notifications](../libraries/microsoft-agents-a365-notifications/docs/design.md) | +| `microsoft-agents-a365-tooling` | MCP server discovery/configuration, tool access and chat-history submission | [Tooling](../libraries/microsoft-agents-a365-tooling/docs/design.md) | + +### Runtime + +`Utility` handles token decoding, agent identity resolution, and user-agent +generation. `OperationResult` and `OperationError` represent operation success or +failure without requiring exceptions for expected failures. +`PowerPlatformApiDiscovery` resolves gateway and island endpoints for different +cloud/cluster environments. These generic utilities remain even where retired +observability code previously called them. + +The observability-specific scope helper and constants are removed. Runtime does +not configure telemetry or select an authentication flow for the external distro. + +### Notifications + +Notification handlers and models remain independent of telemetry configuration. +`AgentNotification` and `AgentHandler` route and handle notification activities; +`AgentLifecycleEvent`, `EmailReference`, `AgentSubChannel`, and notification type +models supply typed metadata. Removing tracing must not remove lifecycle or +notification delivery behavior. + +### Tooling and framework adapters + +`McpToolServerConfigurationService` discovers MCP servers using a local +`ToolingManifest.json` in development or the authenticated gateway in production. +`MCPServerConfig` describes discovered tool-server configuration. + +| Adapter distribution suffix | Integration | Detailed design | +|---|---|---| +| `tooling-extensions-agentframework` | Microsoft Agent Framework | [Agent Framework](../libraries/microsoft-agents-a365-tooling-extensions-agentframework/docs/design.md) | +| `tooling-extensions-azureaifoundry` | Azure AI Foundry | [Azure AI Foundry](../libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/docs/design.md) | +| `tooling-extensions-googleadk` | Google ADK | [Google ADK](../libraries/microsoft-agents-a365-tooling-extensions-googleadk/docs/design.md) | +| `tooling-extensions-openai` | OpenAI Agents | [OpenAI](../libraries/microsoft-agents-a365-tooling-extensions-openai/docs/design.md) | +| `tooling-extensions-semantickernel` | Semantic Kernel | [Semantic Kernel](../libraries/microsoft-agents-a365-tooling-extensions-semantickernel/docs/design.md) | + +All distribution suffixes above are prefixed with `microsoft-agents-a365-`. +Adapters translate discovered MCP tools to their framework's supported APIs. +For example, the OpenAI `McpToolRegistrationService` exposes `send_chat_history` +for a framework session and `send_chat_history_messages` for explicit messages; +these convert framework messages to the core chat-history format and delegate to +the MCP platform. This real-time threat-protection path remains supported. +Chat-history submission is not an observability span exporter. + +## Patterns and data flow + +Core functionality remains framework-agnostic; adapters depend on core packages +and their respective external frameworks. They must not depend on a retired +observability distribution. Some retained frameworks themselves depend on +OpenTelemetry: those transitive dependencies do not restore the retired SDK. + +Expected operation failures use `OperationResult`: ```python result = await some_operation() @@ -350,238 +105,85 @@ else: handle_error(error) ``` -### 5. Strategy Pattern - -`McpToolServerConfigurationService` selects between manifest-based (dev) and gateway-based (prod) configuration loading based on environment. - -## Data Flow - -### Agent Invocation Tracing Flow - -``` -Application - │ - ▼ -BaggageBuilder.set_request_context() ← Set tenant, agent, correlation IDs - │ - ▼ -InvokeAgentScope.start() ← Create root span - │ - ├──▶ SpanProcessor.on_start() ← Copy baggage to span attributes - │ - ▼ -[Agent execution] - │ - ├──▶ InferenceScope.start() ← Child span for LLM calls - │ └── record_input_tokens() - │ └── record_output_tokens() - │ - ├──▶ ExecuteToolScope.start() ← Child span for tool execution - │ └── record_response() - │ - ▼ -scope.record_response() ← Record final response - │ - ▼ -BatchSpanProcessor ← Accumulate spans - │ - ▼ -Agent365Exporter.export() ← Send to backend - ├── Partition by (tenant_id, agent_id) - ├── Resolve app-only OBS token via token_resolver(agent_id, tenant_id) - └── POST to /observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces -``` - -### MCP Tool Discovery Flow - -``` -Application - │ - ▼ -McpToolServerConfigurationService.list_tool_servers() - │ - ▼ -_is_development_scenario()? - │ - ├── YES ─▶ _load_servers_from_manifest() - │ ├── Find ToolingManifest.json - │ ├── Parse JSON configuration - │ └── Return MCPServerConfig list - │ - └── NO ──▶ _load_servers_from_gateway() - ├── Get gateway URL via get_tooling_gateway_for_digital_worker() - ├── HTTP GET with auth token - └── Return MCPServerConfig list -``` - -## Configuration - -### Environment Variables - -| Variable | Purpose | Values | -|----------|---------|--------| -| `ENVIRONMENT` | Controls development vs production behavior | `Development`, `Production` (default) | -| `ENABLE_OBSERVABILITY` | Enable OpenTelemetry tracing | `true`, `false` | -| `ENABLE_A365_OBSERVABILITY` | Enable Agent365-specific tracing | `true`, `false` | -| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable backend exporter | `true`, `false` | -| `AGENT365_PYTHON_SDK_PACKAGE_VERSION` | Build version for packages | Semantic version string | - -### Exporter Options - -```python -from microsoft_agents_a365.observability.core import configure - -configure( - service_name="my-agent", - service_namespace="my-namespace", - token_resolver=lambda agent_id, tenant_id: get_app_only_obs_token(agent_id, tenant_id), - cluster_category="prod", # or "ppe", "test" - # Advanced options via exporter_options parameter: - # max_queue_size=2048, - # scheduled_delay_ms=5000, - # exporter_timeout_ms=30000, - # max_export_batch_size=512, -) -``` - -## Testing - -### Test Structure - -``` -tests/ -├── observability/ -│ ├── core/ -│ │ ├── test_agent365.py # Configuration tests -│ │ ├── test_invoke_agent_scope.py # Agent invocation tests -│ │ ├── test_execute_tool_scope.py # Tool execution tests -│ │ ├── test_inference_scope.py # Inference tests -│ │ ├── test_agent365_exporter.py # Exporter tests -│ │ ├── test_span_processor.py # Span processor tests -│ │ └── test_baggage_builder.py # Context propagation tests -│ └── extensions/ -│ ├── openai/ -│ ├── langchain/ -│ └── agentframework/ -├── runtime/ -│ ├── test_power_platform_api_discovery.py -│ ├── test_utility.py -│ └── test_environment_utils.py -└── tooling/ -``` - -### Running Tests - -```bash -# Run all tests -pytest tests/ - -# Run with coverage -pytest tests/ --cov=libraries --cov-report=html - -# Run specific module tests -pytest tests/observability/core/ -v - -# Run only unit tests -pytest tests/ -m unit +MCP discovery selects the development/production strategy: -# Run only integration tests -pytest tests/ -m integration +```text +Application -> McpToolServerConfigurationService.list_tool_servers() + development -> locate ToolingManifest.json -> parse -> MCPServerConfig list + production -> Runtime gateway discovery -> authenticated HTTP -> server list +Framework adapter -> register discovered tools -> framework tool execution ``` -### Test Conventions - -- **Framework**: pytest with pytest-asyncio for async tests -- **Pattern**: AAA (Arrange → Act → Assert) -- **Naming**: `test___` -- **Markers**: `@pytest.mark.unit` for fast tests, `@pytest.mark.integration` for slower tests - -## Development - -### Prerequisites - -- Python 3.10+ -- [uv](https://github.com/astral-sh/uv) package manager (recommended) or pip -- Git +Chat-history flow remains separate: -### Setup - -```bash -# Clone repository -git clone https://github.com/microsoft/Agent365-python.git -cd Agent365-python - -# Create virtual environment -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate - -# Install dependencies with uv -uv sync - -# Or with pip -pip install -e "libraries/microsoft-agents-a365-observability-core[dev]" +```text +Framework session/messages -> adapter conversion -> core chat-history API + -> authenticated MCP platform request -> OperationResult ``` -### Building +Use explicit tenant/agent identity, existing authentication services, and async +I/O patterns. Preserve error reporting rather than silently skipping behavior. +See individual package design documents for detailed APIs and examples. -```bash -# Set version -export AGENT365_PYTHON_SDK_PACKAGE_VERSION="0.1.0" +## Dependency and release management -# Build all packages -uv build --all-packages --wheel -``` +The root `pyproject.toml` centralizes external version constraints. +Library manifests declare dependency names without local version constraints. +Root `[tool.uv.sources]` entries map internal dependencies to the workspace. +The shared build backend injects centralized constraints into published wheel +metadata and pins internal dependencies to the exact SDK build version. +Run `scripts/verify_constraints.py` after manifest changes. -### Code Quality +CI checks constraints, lint, formatting, builds, and tests on Python 3.11/3.12. +Builds use fresh output directories. `scripts/verify_release_artifacts.py` +requires the exact active wheel set and build version, rejects retired payloads +and dependencies, and runs before publication. Do not copy or upload stale +`dist/*`, old drops, or outputs from historical checkouts. -The project uses [ruff](https://github.com/astral-sh/ruff) for linting and formatting: +Publishing is restricted to SDK-changing pushes on `release/*`. A downstream +job waits for both Python matrix jobs, downloads the Python 3.11 artifact, and +re-verifies those wheels before upload. Tags influence versioning but are +not themselves the workflow's publication trigger. External promotion pipelines, +old branch permissions, and PyPI project retirement require +[owner action](observability-migration.md#release-controls-and-external-actions). +See [versioning and safe builds](../HOW_TO_SET_A_VERSION.md). -```bash -# Check linting -ruff check . +## Development and verification -# Auto-fix issues -ruff check --fix . +Use Python 3.11+ and uv. From the repository root in PowerShell: -# Format code -ruff format . +```powershell +$env:PYTHONPATH = Join-Path (Get-Location) "versioning/helper" +uv sync --locked --all-extras --dev +uv run --frozen python scripts/verify_constraints.py +uv run --frozen ruff check . +uv run --frozen ruff format --check . +uv run --frozen pytest tests -v --tb=short -m "not integration" ``` -**Ruff Configuration** (from `pyproject.toml`): -- Line length: 100 characters -- Target: Python 3.11+ -- Enabled rules: pycodestyle (E, W), Pyflakes (F), isort (I), flake8-bugbear (B), flake8-comprehensions (C4), pyupgrade (UP), copyright (CPY) - -### Package Dependencies +Runtime/Tooling tests and distribution metadata tests remain, including constraint +and artifact-retirement guards. Async tests use pytest-asyncio. All remaining +tests are offline/mocked; the removed observability suite contained the live +integration tests. See [test execution](../tests/RUNNING_TESTS.md). +Each library's manifest also carries its existing strict mypy configuration. -Dependencies between packages are managed via uv workspace: +Set `AGENT365_PYTHON_SDK_PACKAGE_VERSION` before building. Use a fresh directory +and verify it before any promotion or upload: +```powershell +$env:AGENT365_PYTHON_SDK_PACKAGE_VERSION = "0.1.0" +$output = Join-Path ([System.IO.Path]::GetTempPath()) ("agent365-wheels-" + [guid]::NewGuid()) +uv build --all-packages --wheel --out-dir $output +uv run --frozen --no-sync python scripts/verify_release_artifacts.py $output --version $env:AGENT365_PYTHON_SDK_PACKAGE_VERSION ``` -observability-core ◄─── observability-extensions-* - ◄─── observability-hosting - │ - ▼ - runtime ◄─── tooling - │ - ▼ - tooling-extensions-* -``` - -## Key Files Reference -| File | Purpose | -|------|---------| -| [pyproject.toml](../pyproject.toml) | Workspace configuration, dev dependencies, ruff config | -| [libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py](../libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py) | Observability public API | -| [libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/__init__.py](../libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/__init__.py) | Tooling public API | -| [libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py](../libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py) | Runtime public API | -| [libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/__init__.py](../libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/__init__.py) | Notifications public API | -| [tests/TEST_PLAN.md](../tests/TEST_PLAN.md) | Testing strategy and roadmap | +All Python source needs Microsoft copyright headers, type annotations, explicit +`None` checks, and repository-standard error handling. Ruff uses a 100-character +line limit. See [repository instructions](../CLAUDE.md) for the full standards. -## External Resources +## Resources -- [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/) -- [Microsoft 365 Agents SDK Documentation](https://learn.microsoft.com/microsoft-365/agents-sdk/) -- [OpenTelemetry Python Documentation](https://opentelemetry.io/docs/languages/python/) -- [Sample Applications](https://github.com/microsoft/Agent365-Samples/tree/main/python) -- [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) +- [Microsoft Agent 365 developer documentation](https://learn.microsoft.com/microsoft-agent-365/developer/) +- [Microsoft 365 Agents SDK documentation](https://learn.microsoft.com/microsoft-365/agents-sdk/) +- [Sample applications](https://github.com/microsoft/Agent365-Samples/tree/main/python) +- [Observability migration and retirement checklist](observability-migration.md) diff --git a/docs/integrating-with-existing-opentelemetry.md b/docs/integrating-with-existing-opentelemetry.md index bd5bb970..76f89309 100644 --- a/docs/integrating-with-existing-opentelemetry.md +++ b/docs/integrating-with-existing-opentelemetry.md @@ -1,166 +1,20 @@ # Integrating with existing OpenTelemetry -This guide is for developers whose application **already** initializes OpenTelemetry — for example with `azure-monitor-opentelemetry`, an OTLP collector, or a vendor-specific exporter — and who want Agent 365 spans to flow alongside their existing telemetry. If you're starting fresh, see the [observability-core README](../libraries/microsoft-agents-a365-observability-core/README.md) for the standalone setup. - -## The integration rule - -> **Initialize your existing OpenTelemetry stack first, then call Agent 365's `configure()`.** The SDK detects the existing `TracerProvider` and adds its processors to it. Your existing backend receives every span; the Agent 365 backend also receives spans when `ENABLE_A365_OBSERVABILITY_EXPORTER=true` and an app-only OBS `token_resolver` is provided (otherwise `configure()` falls back to `ConsoleSpanExporter`). If the Agent 365 exporter is enabled without a resolver, the console fallback is kept and nothing is sent to Agent 365. - -The detection happens in [`config.py`](../libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py): if a real (non-no-op) `TracerProvider` is already set (detected via a non-None `resource` attribute), `configure()` adds an `_EnrichingBatchSpanProcessor` (wrapping the configured exporter) and a custom `SpanProcessor` to that provider rather than creating a new one. - -## Two minimal patterns - -### Pattern A — `azure-monitor-opentelemetry` - -```python -import os - -from azure.monitor.opentelemetry import configure_azure_monitor -from microsoft_agents_a365.observability.core import configure - -# 1. Existing OTel: Azure Monitor sets up a TracerProvider + AM exporter. -configure_azure_monitor(connection_string=os.environ["APPLICATIONINSIGHTS_CONNECTION_STRING"]) - -# 2. Agent 365 attaches its processors to that same TracerProvider. -configure( - service_name="my-agent", - service_namespace="my-namespace", - token_resolver=my_app_only_obs_token_resolver, -) -``` - -→ Runnable version: [`observability-with-azure-monitor`](https://github.com/microsoft/Agent365-Samples/tree/main/python/observability-with-azure-monitor) sample. - -### Pattern B — manual OTel SDK + OTLP exporter - -```python -import os - -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.resources import SERVICE_NAME, Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -from microsoft_agents_a365.observability.core import configure - -# 1. Existing OTel: build provider + OTLP exporter explicitly. -provider = TracerProvider(resource=Resource.create({SERVICE_NAME: "my-agent"})) -provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"])) -) -trace.set_tracer_provider(provider) - -# 2. Agent 365 attaches to that same provider. -configure( - service_name="my-agent", - service_namespace="my-namespace", - token_resolver=my_app_only_obs_token_resolver, -) -``` - -→ Runnable version: [`observability-with-otlp`](https://github.com/microsoft/Agent365-Samples/tree/main/python/observability-with-otlp) sample (defaults to `ConsoleSpanExporter` for zero setup). - -The Agent 365 backend exporter always uses -`/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`. -The resolver must return an app-only OBS token for the exporting agent identity; -delegated workload/OBO tokens are not read from request context and are rejected -by the S2S service. Cache the resolver result and refresh near expiry because it -is invoked for each export batch/identity group. - -## Auto-instrumentation vs. manual instrumentation - -The OTel **backend** (where spans go) and the **instrumentation style** (how spans are produced) are independent axes. You can mix them freely. - -| | Auto (extension package) | Manual (`InvokeAgentScope` / `InferenceScope` / `ExecuteToolScope`) | -|--------------------------|-------------------------------------------------------------------|--------------------------------------------------------------------| -| **Azure Monitor** | Demonstrated by `observability-with-azure-monitor` sample | Same `configure()`; replace agent code with manual scope wrapping | -| **OTLP / vendor-neutral** | Same `configure()`; install your framework's extension package | Demonstrated by `observability-with-otlp` sample | - -For auto-instrumentation, install the framework-specific extension package — for example: - -- OpenAI Agents SDK → `microsoft-agents-a365-observability-extensions-openai` -- LangChain → `microsoft-agents-a365-observability-extensions-langchain` -- Semantic Kernel → `microsoft-agents-a365-observability-extensions-semantickernel` -- Microsoft Agent Framework → `microsoft-agents-a365-observability-extensions-agentframework` - -For the OpenAI Agents SDK extension, instantiate `OpenAIAgentsTraceInstrumentor()` and call `.instrument()` **after** `configure()`. The instrumentor raises `RuntimeError` if Agent 365 isn't configured first. - -## What spans should I expect to see? - -The SDK produces three core span kinds. Your backend should show them in this typical hierarchy: - -| `gen_ai.operation.name` | Produced by | Typical parent | Span name (default) | Notes | -|-------------------------|---------------------------------------------------|----------------|---------------------|-------| -| `invoke_agent` | `InvokeAgentScope` (one per user turn) | (root or app) | `invoke_agent ` when set, else `invoke_agent` | | -| `chat` / `text_completion` / `generate_content` | `InferenceScope` (one per LLM call) | `invoke_agent` | ` ` | Manual inference scopes use canonical lowercase [OTel GenAI operation names](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/), matching auto-instrumentation for the corresponding operation. | -| `execute_tool` | `ExecuteToolScope` (one per tool invocation) | `invoke_agent` | `execute_tool ` (always includes the tool name) | Records tool name, args, and result. | - -Filter your backend by the `gen_ai.operation.name` attribute or by span name. -`InferenceScope` emits `chat`, `text_completion`, or `generate_content` according -to the selected `InferenceOperationType`, with span names such as `chat gpt-4`. -`inference` is not a literal operation value. When upgrading, update case-sensitive -filters that matched the previous `Chat`, `TextCompletion`, or `GenerateContent` -values and span-name prefixes, or compare case-insensitively. - -## Verifying the integration - -If you've called `configure()` but don't see Agent 365 spans in your backend, isolate the problem by adding a `ConsoleSpanExporter` temporarily: - -```python -from opentelemetry import trace -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter - -# After configure() has run: -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) -``` - -Run a single turn. If you see `invoke_agent`, the inference operation -(`chat`, `text_completion`, or `generate_content`), and any invoked `execute_tool` -spans as JSON dumps on stdout, the SDK is producing spans correctly - investigate -your backend exporter (network, auth, sampling). If expected spans are missing -from stdout too, check the integration pitfalls below. - -## Common pitfalls - -### Pitfall 1: Calling `configure_azure_monitor()` after Agent 365 `configure()` - -**Symptom:** Agent 365 spans don't appear in any backend. - -**Cause:** `configure_azure_monitor` (and many vendor packages) replace the global `TracerProvider`. If they run *after* `configure()`, the provider with our processors is discarded. - -**Fix:** Always initialize Azure Monitor (or any OTel setup) **before** calling Agent 365 `configure()`. - -### Pitfall 2: Calling Agent 365 `configure()` before app's OTel setup - -**Symptom:** Same as above — Agent 365 spans are missing. - -**Cause:** `configure()` creates its own `TracerProvider` (no existing one detected). Your app's later OTel init replaces it, dropping our processors. - -**Fix:** Same as Pitfall 1 — OTel first, then Agent 365. - -### Pitfall 3: `OTEL_SDK_DISABLED=true` or `OTEL_TRACES_EXPORTER=none` - -**Symptom:** Nothing exports — neither your existing backend nor Agent 365. - -**Cause:** These environment variables disable OpenTelemetry SDK-wide. They suppress Agent 365 spans alongside everything else. - -**Fix:** Use sampling (`OTEL_TRACES_SAMPLER`) or per-exporter configuration instead of the global disable. If you intentionally want to disable tracing in a particular environment, that's fine — just understand it disables Agent 365 too. - -### Pitfall 4: `ENABLE_OBSERVABILITY` not set - -**Symptom:** Your existing OTel backend works (Azure Monitor / OTLP / etc. show spans), but Agent 365 scope blocks (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`) produce zero spans. No errors, no warnings. - -**Cause:** Agent 365's scope classes gate span creation on the `ENABLE_OBSERVABILITY` (or `ENABLE_A365_OBSERVABILITY`) environment variable. If neither is set to `true` / `1` / `yes` / `on`, every scope's `__init__` skips span creation entirely. This is **independent** of OTel's own enable/disable mechanism — your existing OTel telemetry continues to flow normally. - -**Fix:** Set `ENABLE_OBSERVABILITY=true` (or `ENABLE_A365_OBSERVABILITY=true`) in your environment before creating any scopes / emitting spans (the check happens at scope construction time, not import time). Both runnable samples include this in their `.env.template`. - -## Exporter combinations - -| Combination | What's installed | What to call | Gotchas | -|-----------------------------------------|---------------------------------------------------------------------------|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| -| Azure Monitor only | `azure-monitor-opentelemetry` | `configure_azure_monitor(...)` | Standard Azure Monitor — no Agent 365 spans flow. | -| Azure Monitor + Agent 365 | `azure-monitor-opentelemetry`, `microsoft-agents-a365-observability-core` | `configure_azure_monitor(...)` then `configure(...)` | Order matters (see Pitfall 1). | -| OTLP collector + Agent 365 | `opentelemetry-sdk`, `opentelemetry-exporter-otlp-*`, A365 core | Build provider + `BatchSpanProcessor(OTLPSpanExporter(...))` then `configure(...)` | Set `OTEL_EXPORTER_OTLP_ENDPOINT`; collector must be reachable. | -| Agent 365 only | `microsoft-agents-a365-observability-core` | `configure(...)` only | SDK creates its own `TracerProvider`; spans go to Agent 365 backend only. | -| OTLP + Azure Monitor + Agent 365 | All of the above | Configure Azure Monitor first; add OTLP `BatchSpanProcessor` to the provider; call `configure(...)` | All three exporters receive every span. Watch out for duplicate processors if Azure Monitor itself adds OTLP. | +**The Agent365 observability SDK formerly documented here is retired from this +repository.** Its `configure()` API, exporter options, and framework instrumentors +are no longer shipped. Do not follow historical setup-order examples as current +instructions. + +Use [Microsoft OpenTelemetry Distro](https://github.com/microsoft/opentelemetry-distro-python) +([`microsoft-opentelemetry` on PyPI](https://pypi.org/project/microsoft-opentelemetry/)) +and its `microsoft.opentelemetry.use_microsoft_opentelemetry` initializer. +Start with the [repository migration guide](observability-migration.md), then +follow the distro's [official Python migration](https://github.com/microsoft/opentelemetry-distro-python/blob/main/MIGRATION_A365.md) +and [Agent365 configuration documentation](https://github.com/microsoft/opentelemetry-distro-python/blob/main/A365_DOCUMENTATION.md) +for your selected release. + +Review tracer-provider initialization, exporter combinations, identity and +authentication, baggage propagation, instrumentation, and shutdown explicitly. +Do not configure competing global providers or duplicate framework instrumentors. +Generic OTLP delivery alone does not preserve Agent365 routing, enrichment, +authentication, or portal behavior. No drop-in compatibility is promised. diff --git a/docs/observability-migration.md b/docs/observability-migration.md new file mode 100644 index 00000000..a8dd744f --- /dev/null +++ b/docs/observability-migration.md @@ -0,0 +1,344 @@ +# Retiring Agent365 Observability: migration and package-owner actions + +The Python observability implementations have moved out of this repository. +New builds contain only Notifications, Runtime, Tooling, and the five Tooling +extensions. Previously published observability releases are not deleted or +yanked by this change. Removing source does not uninstall deployed applications, +update their installed code, or change PyPI project settings. + +Use the **[Microsoft OpenTelemetry Distro repository](https://github.com/microsoft/opentelemetry-distro-python)** +and **[`microsoft-opentelemetry` on PyPI](https://pypi.org/project/microsoft-opentelemetry/)**. +The onboarding import is: + +```python +from microsoft.opentelemetry import use_microsoft_opentelemetry +``` + +This is **not a drop-in replacement**. Review the official +[Python Agent365 migration guide](https://github.com/microsoft/opentelemetry-distro-python/blob/main/MIGRATION_A365.md), +[Agent365 API/configuration documentation](https://github.com/microsoft/opentelemetry-distro-python/blob/main/A365_DOCUMENTATION.md), +and [Microsoft Learn onboarding](https://learn.microsoft.com/microsoft-agent-365/developer/microsoft-opentelemetry?tabs=python). +The [archived SDK documentation](https://learn.microsoft.com/microsoft-agent-365/developer/archive/observability-sdk) +describes the old SDK, not the current onboarding path. + +## What was removed + +Distribution names below are taken from the removed package manifests, not +inferred from directory names. In particular, `agent-framework` and +`semantic-kernel` contain extra hyphens in the **distribution** names. + +All source paths were under `libraries/`; all dedicated test paths under `tests/`. + +| Retired distribution | Former source directory | Dedicated tests | Former internal dependencies | +|---|---|---|---| +| `microsoft-agents-a365-observability-core` | `microsoft-agents-a365-observability-core` | `observability/core` | Runtime | +| `microsoft-agents-a365-observability-hosting` | `microsoft-agents-a365-observability-hosting` | `observability/hosting` | Core, Runtime | +| `microsoft-agents-a365-observability-extensions-agent-framework` | `microsoft-agents-a365-observability-extensions-agentframework` | `observability/extensions/agentframework` | Core | +| `microsoft-agents-a365-observability-extensions-langchain` | `microsoft-agents-a365-observability-extensions-langchain` | `observability/extensions/langchain` | Core | +| `microsoft-agents-a365-observability-extensions-openai` | `microsoft-agents-a365-observability-extensions-openai` | `observability/extensions/openai` | Core | +| `microsoft-agents-a365-observability-extensions-semantic-kernel` | `microsoft-agents-a365-observability-extensions-semantickernel` | `observability/extensions/semantickernel` | Core | + +These packages shared workspace versioning; published internal dependencies were +pinned to the build version. All six workspace entries, manifests, sources, +dedicated tests, and the observability-only usage example are removed. +The retired suite contained all live integration tests in this checkout; its +otherwise-empty CI/tox integration job and credential example are removed too. +Remaining mocked Tooling end-to-end tests continue to run. +Existing changelog entries are preserved unchanged in +[`docs/changelogs`](changelogs/). Historical PRDs describe the design at the time, +not the current package inventory. + +Runtime's `get_observability_authentication_scope()`, +`PROD_OBSERVABILITY_SCOPE`, and `PROD_OBSERVABILITY_CLUSTER_CATEGORY` are removed. +Generic environment detection, JWT/identity utilities, operation results, and +Power Platform endpoint discovery remain. No remaining library imports the +removed namespace. Notifications and Tooling behavior, including MCP chat-history +submission, is not replaced by telemetry or removed. + +Only exclusive dependencies are removed: LangChain development/instrumentation +dependencies, `wrapt`, the OTLP exporter umbrella/gRPC exporter, OTel +instrumentation, and Azure Monitor exporter/ingestion dependencies. OTel API/SDK +and HTTP/GCP exporters can still appear transitively through retained AI +frameworks; their presence does not mean Agent365 observability is still shipped. + +## Consumer installation + +Inventory imports and direct/transitive requirements first. Test in an isolated +environment and retain your previous lockfile for rollback. Remove the six +requirements from your manifests, extras, constraints, containers, and deployment +scripts; regenerate your own lockfile with your package manager. + +```powershell +python -m pip uninstall -y microsoft-agents-a365-observability-core microsoft-agents-a365-observability-hosting microsoft-agents-a365-observability-extensions-agent-framework microsoft-agents-a365-observability-extensions-langchain microsoft-agents-a365-observability-extensions-openai microsoft-agents-a365-observability-extensions-semantic-kernel +python -m pip install microsoft-opentelemetry +# If you use Microsoft Agents SDK hosting middleware, install its optional extra: +python -m pip install "microsoft-opentelemetry[hosting]" +python -m pip check +``` + +Do **not** uninstall Runtime, Notifications, Tooling, or shared OpenTelemetry and +framework packages just because observability used them. Keep those needed by +your application. Choose and lock a tested distro version rather than assuming +the latest source API is available in every historical PyPI release. + +**Override upstream migration Step 1 for applications using Tooling (including +its extensions) or Runtime directly:** keep `microsoft-agents-a365-runtime` and +its generic imports; do not follow the upstream Runtime uninstall/import rewrite +for those usages. The distro's Runtime utilities are not a replacement for the +Runtime dependency of Tooling. Notifications remains supported independently; +it does not require Runtime unless your application uses Runtime separately. + +## Imports, APIs, and initialization + +| Old surface | Migration | +|---|---| +| `microsoft_agents_a365.observability.core` models, scopes, `BaggageBuilder` | Import supported equivalents from `microsoft.opentelemetry.a365.core`; check signatures in the official migration guide. | +| `configure(service_name, service_namespace, ...)` | Replace startup configuration with `use_microsoft_opentelemetry(enable_a365=True, ...)`; parameters are not positionally compatible. Set service resource attributes using the distro's documented configuration. | +| `token_resolver`, `exporter_options` | Use documented `a365_*` options, such as `a365_token_resolver`; old options objects are not accepted. | +| `microsoft_agents_a365.observability.hosting` | Use `microsoft.opentelemetry.a365.hosting` with the `[hosting]` extra; explicitly configure middleware. | +| Manual framework instrumentors and `.instrument()` calls | Remove retired instrumentor imports and use distro auto-instrumentation; see framework notes below. | +| Runtime observability scope helper | Use `microsoft.opentelemetry.a365.runtime.get_observability_authentication_scope()` only for the matching delegated route; its default differs from the removed app-only helper. See authentication below. | +| `extract_context_from_headers`, `get_traceparent` | Use standard OTel propagation APIs, preserving the required trust boundary and baggage policy. | +| `is_configured()`, exporter options, old enricher registry/processor APIs | No compatible public substitute is promised. Redesign using documented distro options and supported OTel processors. | + +Initialize once, before creating framework clients or handling requests. For an +application retaining the old SDK's **app-only S2S** export route, the following +startup function accepts your own synchronous, production token resolver: + +```python +import os +from collections.abc import Callable + +from microsoft.opentelemetry import use_microsoft_opentelemetry + + +def initialize_telemetry(token_resolver: Callable[[str, str], str]) -> None: + os.environ["ENABLE_OBSERVABILITY"] = "true" + os.environ["ENABLE_A365_OBSERVABILITY_EXPORTER"] = "true" + use_microsoft_opentelemetry( + enable_a365=True, + a365_use_s2s_endpoint=True, + a365_token_resolver=token_resolver, + ) +``` + +Supply a resolver that acquires and refreshes the correct app-only token for +`(agent_id, tenant_id)`; a cached token string is not a permanent credential. +Do not log tokens. The `ENABLE_OBSERVABILITY` gate controls scope emission; +enabling Agent365 integration alone is not sufficient. The exporter flag controls +Agent365 delivery separately from enrichment for other exporters. +Do not initialize another competing global tracer provider afterward. Follow the +distro's startup, exporter, flushing, and shutdown guidance when combining Azure +Monitor, OTLP, and Agent365. + +### Authentication is route-specific + +The removed SDK used `/observabilityService` with an app-only OBS-resource +`/.default` token; its `use_s2s_endpoint` option was deprecated and ignored. +**The distro's similarly named `a365_use_s2s_endpoint` is meaningful**: set it +explicitly when retaining S2S, and supply the matching resolver. It selects a +route, not an automatic S2S authentication implementation. The official +[S2S resolver sample](https://github.com/microsoft/opentelemetry-distro-python/blob/main/samples/a365/s2s/token_resolver.py) +uses `api://9b975845-388f-4429-889e-eab1ef63949c/.default`. + +The replacement helper is +`microsoft.opentelemetry.a365.runtime.get_observability_authentication_scope()`. +Without an environment override, it returns a `list[str]` containing +`api://9b975845-388f-4429-889e-eab1ef63949c/Agent365.Observability.OtelWrite` +for the default delegated/agentic route. **Do not use that default as-is in an +app-only S2S resolver.** Acquire the app-only `/.default` scope shown above instead. +An explicit `a365_observability_scope_override` must match that route; a custom +resolver must use the matching scope itself. The helper reads +`A365_OBSERVABILITY_SCOPE_OVERRIDE`, not the initialization keyword argument. + +By contrast, the verified distro default FIC resolver requests +`api://9b975845-388f-4429-889e-eab1ef63949c/Agent365.Observability.OtelWrite` +through a delegated `user_fic` exchange, requiring service-connection +configuration, agent instance identity, and agentic-user identity. It is not +equivalent to the old SDK's app-only scope. Consult the official documentation +for required `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__*`, +`A365_AGENT_APP_INSTANCE_ID`, and `A365_AGENTIC_USER_ID` configuration and consent. +When no explicit token resolver is supplied, the distro selects FIC when its +required environment variables are present; otherwise it falls back to +`DefaultAzureCredential`. It does not retry failed FIC exchanges through that +credential chain. The fallback uses the identity selected by the chain, not +necessarily the intended agent identity. Use an explicit, route-appropriate +resolver in production rather than relying on implicit credential selection. +This selection and the scope helper were verified against upstream +[`utils.py`](https://github.com/microsoft/opentelemetry-distro-python/blob/22d129d379060aa38ca7b54f91582a860c4eb0cf/src/microsoft/opentelemetry/a365/core/exporters/utils.py) +and +[`environment_utils.py`](https://github.com/microsoft/opentelemetry-distro-python/blob/22d129d379060aa38ca7b54f91582a860c4eb0cf/src/microsoft/opentelemetry/a365/runtime/environment_utils.py). + +The distro also supports `a365_contextual_token_resolver` with a +`TokenResolverContext`; it takes precedence over the simple resolver. +`a365_observability_scope_override` / `A365_OBSERVABILITY_SCOPE_OVERRIDE` must +match the selected identity, cloud, resource, and route. Validate tenant isolation, +permissions, token renewal, endpoint routing, and actual ingestion in a test tenant. +An HTTP success or local console span alone is not proof of Agent365 behavior. + +### Context and hosting + +Use the distro's supported `BaggageBuilder` context manager: + +```python +from microsoft.opentelemetry.a365.core import BaggageBuilder + + +def handle_request(tenant_id: str, agent_id: str) -> None: + with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): + # Run instrumented agent work within this context. + pass +``` + +Supply tenant and agent identity per request, not in mutable process-global state. +The Agent365 exporter requires both for routing and can drop spans without them. +Check agentic-user versus human-user identity semantics when populating baggage. +For inbound/outbound HTTP, use `opentelemetry.propagate.extract` / `inject` and +attach/detach the extracted OTel context with `try/finally`. Validate allowed +baggage at ingress; do not trust unvalidated tenant/agent IDs from callers. +Verify propagation across async work, background tasks, tools, and service hops. +Custom baggage intended as span attributes requires the distro's explicit custom +attribute opt-in; arbitrary `set_pairs()` entries are not automatically copied. + +For hosting middleware, import `ObservabilityHostingManager` and +`ObservabilityHostingOptions` from `microsoft.opentelemetry.a365.hosting`, then call +`ObservabilityHostingManager.configure(adapter.middleware_set, options)` once. +Set `ObservabilityHostingOptions(enable_baggage=True, enable_output_logging=True)` +only for the features you need: **both default to false**. Merely changing imports +does not enable baggage or output-message spans. The first singleton configuration +wins. Review privacy/content recording and request identity population explicitly. + +### Framework instrumentation and unsupported surfaces + +The distro discovers Agent Framework, LangChain, Semantic Kernel, and OpenAI +Agents instrumentation automatically. Framework packages must be installed and +versions must meet upstream support requirements; the distro is not the framework +runtime. Remove the old wrapper/trace-instrumentor setup to avoid duplicate spans. +The A365-specific OpenAI Agents instrumentor and the general OpenAI Agents +instrumentor are selected mutually exclusively. Review `instrumentation_options` +for opt-outs. For Agent365-only mode, general HTTP/web/database instrumentation +is disabled by default; enable required libraries explicitly. + +Do not assume the former custom span enrichers, `SpanProcessor`, +`EnrichedReadableSpan`, `Agent365ExporterOptions`, or `SpectraExporterOptions` +have compatible public APIs. Spectra sidecar integration requires a deliberate +move to the distro's standard OTLP configuration, not an options-class rename. +Generic OTLP export **does not preserve** Agent365 authentication, tenant/agent +routing, enrichment, content capture, output logging, or portal ingestion by itself. +Review manual invoke/inference/tool/guardrail/output scopes and their models one +by one against the official migration guide. Validate span relationships, +attributes, message serialization, token usage, sampling, filtering, retries, +offline storage, shutdown delivery, and sensitive-data policy in your application. +Unsupported or undocumented custom behavior needs application changes or an +upstream support decision; this repository supplies no compatibility shim. + +## Owner-operated PyPI retirement checklist + +These are **manual project-owner actions**, not performed by this repository change. +Apply them to each of the six exact distribution names above; audit any historical +aliases separately rather than assuming directory names are published aliases. + +- [ ] Confirm owners, published versions, dependents, support/security policy, and + the public migration URL. Coordinate documentation, samples, internal feeds, + enterprise catalogs, and customer announcements. +- [ ] Decide whether a final, separately approved maintenance release is needed + to update the PyPI long description and project URLs with the notice below. + Published files/metadata cannot be overwritten by editing this checkout. + Do not publish empty tombstone packages or force-install the replacement as a + dependency: neither provides API compatibility. +- [ ] If desired, include a visible, carefully scoped runtime warning in that + final release. Python `DeprecationWarning` is normally filtered; `FutureWarning` + is normally visible. Test your warning policy. Already-installed older versions + do not gain warnings, and users pinned to them may never fetch a new release. + Announce retirement through release notes, documentation, and direct channels too. +- [ ] After any approved final release, use PyPI **project archival** for each + project to mark it unmaintained and block further uploads. Existing releases + remain installable; archival is reversible and does not migrate dependents. +- [ ] Do not delete published releases or yank them by default. Yanking is for a + specific broken/security-affected release and changes resolver selection; exact + pins can still install it. Record a reason and assess consumers before any yank. +- [ ] Remove or restrict retired-project upload credentials/trusted publishers, + including external pipelines and old branches, while preserving publishing for + the eight active distributions. Verify the retirement on each PyPI project page. + +PyPI provides [project archival](https://docs.pypi.org/project-management/archiving-projects/) +and [release yanking](https://docs.pypi.org/project-management/yanking/). +It does **not** provide NuGet-style structured replacement-package recommendations, +automatic install redirection, or a guaranteed pip warning to existing consumers. +An archival banner and a release description linking the migration are supported +ways to discourage new use; neither uninstalls or rewrites consumers' environments. +See also [PyPI's archival announcement](https://blog.pypi.org/posts/2025-01-30-archival/). + +### Ready-to-paste retirement notice + +> **Retired: Microsoft Agent365 Observability SDK for Python** +> +> This observability distribution is retired and is no longer built or released +> from Agent365-python. For new development, use Microsoft OpenTelemetry Distro: +> https://github.com/microsoft/opentelemetry-distro-python +> and install `microsoft-opentelemetry`: +> https://pypi.org/project/microsoft-opentelemetry/. +> +> Existing applications must review the migration guide before changing packages: +> https://github.com/microsoft/Agent365-python/blob/main/docs/observability-migration.md +> and the official Python API migration: +> https://github.com/microsoft/opentelemetry-distro-python/blob/main/MIGRATION_A365.md. +> This is not a drop-in replacement. Authentication, initialization, propagation, +> and framework instrumentation require review. Previously published versions +> remain available for existing consumers. Notifications, Runtime, and Tooling +> continue in Agent365-python. + +Publish this link only after the migration guide is available on the target +branch. Do not promise support timelines without the package owners' approval. + +## Release controls and external actions + +The checked-in CI builds into a fresh per-job directory, validates the exact +workspace wheel set, version, wheel metadata, dependencies (including extras), +and payload, then copies/uploads only those wheels. The sole publishing job waits +for both Python matrix jobs to succeed and downloads their verified Python 3.11 +artifact into a fresh directory. The verifier runs again +immediately before publication and rejects stale versions, extra files, retired +distributions, retired dependencies, and bundled retired source. Upload failures +are no longer ignored. The build remains wheel-only; no sdist upload path is added. +Use `scripts/verify_release_artifacts.py DIRECTORY --version VERSION` for manual +wheel builds too. Never upload an old checkout's `dist/*` or replay old artifacts. + +**Repository edits cannot enforce retirement outside this revision.** Owners must: + +- [ ] Backport the removal/guard to every maintained `release/*` branch, or disable + publishing from those branches. Old tags/checkouts and reruns of old workflow + commits still contain the old build and upload logic. +- [ ] Audit GitHub Actions environments, repository/organization secrets and trusted + publishers, branch restrictions, queued jobs, manual workflows, and reusable + workflows outside this repository. Cancel obsolete publishing runs and prevent + historical workflow reruns from using live retired-project credentials. + Remove unused observability integration-test credentials and + `RUN_INTEGRATION_TESTS` configuration after checking other workflows' needs. +- [ ] Before enabling release uploads, set the repository variable + `AGENT365_PACKAGE_REPOSITORY_URL` to the approved package-upload endpoint and + configure the `AZURE_DEVOPS_TOKEN` secret with the appropriate feed permissions. + The workflow passes this URL directly to Twine and fails explicitly if it is + missing; no `.pypirc` is required and there is no default public PyPI target. +- [ ] Audit external Azure DevOps/release definitions, approvals, service connections, + artifact promotion/retention, and package-feed-to-PyPI mirroring. A configured + upload endpoint is not proof of direct public PyPI publishing. The external + configuration and downstream promotions are not defined here. +- [ ] Remove retired artifacts from pending release inputs/caches/staging drops + without deleting public releases or unrelated artifacts. Require the active + distribution allowlist at every external upload/promotion boundary. + +The current workflow publishes on SDK-changing pushes to `release/*`, including +development versions; a tag is not required by its publish condition. Restrict +external permissions accordingly rather than relying on tag policy alone. + +## Verification sources + +Upstream APIs and documentation were checked on 2026-10-28 against +[`microsoft/opentelemetry-distro-python` commit `81ba38b6`](https://github.com/microsoft/opentelemetry-distro-python/tree/81ba38b6da60f4a126266640a9192591e5833e35) +(`README.md`, `MIGRATION_A365.md`, `A365_DOCUMENTATION.md`, and the A365 source +and S2S sample). PyPI listed +[`microsoft-opentelemetry` 1.5.0](https://pypi.org/project/microsoft-opentelemetry/1.5.0/). +Main-branch APIs and released versions can diverge; verify your selected version +against its own release documentation. PyPI lifecycle guidance above is based +on the linked official PyPI documentation, not another registry's capabilities. diff --git a/docs/prd/PRD-CentralizedPackageVersions.md b/docs/prd/PRD-CentralizedPackageVersions.md index e4741642..64c943bb 100644 --- a/docs/prd/PRD-CentralizedPackageVersions.md +++ b/docs/prd/PRD-CentralizedPackageVersions.md @@ -1,5 +1,10 @@ # Product Requirements Document: Centralized Package Version Constraints +> **Historical design:** Package inventories and observability examples below +> describe the original proposal, not current onboarding or release inputs. +> Observability is retired; use the [migration guide](../observability-migration.md) +> and the [current architecture](../design.md). + **Document Version:** 1.2 **Created:** 2026-01-27 **Status:** Draft diff --git a/docs/prd/tasks/CentralizedPackageVersions-Tasks.md b/docs/prd/tasks/CentralizedPackageVersions-Tasks.md index 8ee88ff3..caeb1268 100644 --- a/docs/prd/tasks/CentralizedPackageVersions-Tasks.md +++ b/docs/prd/tasks/CentralizedPackageVersions-Tasks.md @@ -1,5 +1,10 @@ # Task Breakdown: Centralized Package Version Constraints +> **Historical task plan:** Observability package names and examples below are +> retained as design history, not instructions to recreate or release them. +> See the [migration guide](../../observability-migration.md) and +> [current architecture](../../design.md) for the active workspace. + **Parent PRD:** [PRD-CentralizedPackageVersions.md](../PRD-CentralizedPackageVersions.md) **Created:** 2026-01-27 **Status:** Ready for Implementation diff --git a/generate_dependency_diagram.py b/generate_dependency_diagram.py index c0d5bced..76083c31 100644 --- a/generate_dependency_diagram.py +++ b/generate_dependency_diagram.py @@ -51,12 +51,6 @@ def generate_mermaid_diagram(packages: list[PackageInfo]) -> str: colors = { "Notifications": {"fill": "#ffcdd2", "stroke": "#c62828", "color": "#280505"}, # Red "Runtime": {"fill": "#bbdefb", "stroke": "#1565c0", "color": "#0d1a26"}, # Blue - "Observability": {"fill": "#c8e6c9", "stroke": "#2e7d32", "color": "#142a14"}, # Green - "Observability Extensions": { - "fill": "#e8f5e9", - "stroke": "#66bb6a", - "color": "#1f3d1f", - }, # Light Green "Tooling": {"fill": "#ffe0b2", "stroke": "#e65100", "color": "#331a00"}, # Orange "Tooling Extensions": { "fill": "#fff3e0", @@ -122,10 +116,6 @@ def determine_package_type(package_name: str) -> str: return "Notifications" elif "runtime" in package_name: return "Runtime" - elif "observability-extensions" in package_name: - return "Observability Extensions" - elif "observability" in package_name: - return "Observability" elif "tooling-extensions" in package_name: return "Tooling Extensions" elif "tooling" in package_name: @@ -212,10 +202,6 @@ def main(): f.write("## Package Types\n\n") f.write("- **Notifications** (Red): Notification and messaging extensions\n") f.write("- **Runtime** (Blue): Core runtime components\n") - f.write("- **Observability** (Green): Telemetry and monitoring core\n") - f.write( - "- **Observability Extensions** (Light Green): Framework-specific observability integrations\n" - ) f.write("- **Tooling** (Orange): Agent tooling SDK core\n") f.write( "- **Tooling Extensions** (Light Orange): Framework-specific tooling integrations\n" diff --git a/libraries/microsoft-agents-a365-observability-core/README.md b/libraries/microsoft-agents-a365-observability-core/README.md deleted file mode 100644 index 51a0300c..00000000 --- a/libraries/microsoft-agents-a365-observability-core/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# microsoft-agents-a365-observability-core - -[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-observability-core?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-core) -[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-observability-core?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-core) - -Telemetry, tracing, and monitoring components for AI agents built on OpenTelemetry. This package provides structured spans for agent invocation, tool execution, and LLM inference with context propagation and pluggable exporters. - -> **Already using OpenTelemetry?** This SDK detects an existing `TracerProvider` and adds its processors to it — your spans flow to your existing backend (Azure Monitor, OTLP collector, vendor exporter, etc.) and, when `ENABLE_A365_OBSERVABILITY_EXPORTER` is enabled with a configured `token_resolver`, also to the Agent 365 backend. See [Integrating with existing OpenTelemetry](../../docs/integrating-with-existing-opentelemetry.md) for setup patterns and troubleshooting. - -## Installation - -```bash -pip install microsoft-agents-a365-observability-core -``` - -## Usage - -For usage examples and detailed documentation, see the [Observability documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=python) on Microsoft Learn. - -### Agent 365 OBS export authentication - -When `ENABLE_A365_OBSERVABILITY_EXPORTER` is enabled, exports always use the -S2S OTLP route: - -```text -/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 -``` - -The deprecated `use_s2s_endpoint` option is ignored, even when set to `False`; -domain overrides change only the host. The exporter never falls back to -`/observability` and never reads delegated request-context tokens. - -Provide an app-only OBS `token_resolver(agent_id, tenant_id)` for the exporting -agent identity. The resolver is invoked for each export batch and identity -group, so it should cache the acquired token and refresh near expiry. Empty -tokens or resolver failures fail that export batch without sending an HTTP -request or retrying on a delegated route. If the Agent 365 exporter is enabled -without a resolver, the existing `ConsoleSpanExporter` fallback is kept and -nothing is sent to Agent 365. - -Resolvers should request the OBS resource `/.default` scope -(`api://9b975845-388f-4429-889e-eab1ef63949c/.default`) and validate the token -before returning it: reject any `scp` claim and any `idtyp` other than `app`; -when `idtyp` is absent, accept only a non-empty `roles` array or a non-empty -`oid` equal to `sub`; also verify `aud` is the OBS resource and the token is not -expired. Workload authentication for MCP, Microsoft Graph, and other OBO calls -is separate and unchanged. - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details. diff --git a/libraries/microsoft-agents-a365-observability-core/docs/design.md b/libraries/microsoft-agents-a365-observability-core/docs/design.md deleted file mode 100644 index e4710162..00000000 --- a/libraries/microsoft-agents-a365-observability-core/docs/design.md +++ /dev/null @@ -1,467 +0,0 @@ -# Observability Core - Design Document - -This document describes the architecture and design of the `microsoft-agents-a365-observability-core` package. - -## Overview - -The observability core package provides OpenTelemetry-based distributed tracing infrastructure for AI agent applications. It enables comprehensive observability by tracing agent invocations, LLM inference calls, and tool executions. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Public API │ -│ configure() | get_tracer() | InvokeAgentScope | BaggageBuilder │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ TelemetryManager │ -│ (Thread-safe Singleton) │ -│ - TracerProvider management │ -│ - SpanProcessor registration │ -│ - Exporter configuration │ -└─────────────────────────────────────────────────────────────────┘ - │ - ┌──────────────────┼──────────────────┐ - ▼ ▼ ▼ -┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ SpanProcessor │ │ BatchSpanProcessor│ │ Agent365Exporter │ -│ (Custom baggage │ │ (OTEL SDK) │ │ (HTTP export) │ -│ propagation) │ │ │ │ │ -└──────────────────┘ └──────────────────┘ └──────────────────┘ -``` - -## Key Components - -### Configuration ([config.py](../microsoft_agents_a365/observability/core/config.py)) - -The `TelemetryManager` class is a thread-safe singleton that manages telemetry configuration: - -```python -from microsoft_agents_a365.observability.core import configure - -configure( - service_name="my-agent", - service_namespace="my-namespace", - token_resolver=lambda agent_id, tenant_id: get_app_only_obs_token(agent_id, tenant_id), - cluster_category="prod" -) -``` - -**Key behaviors:** -- Creates or reuses an existing `TracerProvider` -- Adds `BatchSpanProcessor` for span export -- Adds custom `SpanProcessor` for baggage-to-attribute copying -- Falls back to `ConsoleSpanExporter` if token resolver is not provided - -### Scope Classes - -#### Base Class ([opentelemetry_scope.py](../microsoft_agents_a365/observability/core/opentelemetry_scope.py)) - -`OpenTelemetryScope` is the base class for all tracing scopes: - -```python -class OpenTelemetryScope: - """Base class for OpenTelemetry tracing scopes.""" - - def __init__(self, kind, operation_name, activity_name, agent_details): - # Creates span with given parameters - # Sets common attributes (gen_ai.system, operation name) - # Sets agent details as span attributes - - def __enter__(self): - # Makes span active in current context - - def __exit__(self, exc_type, exc_val, exc_tb): - # Records exception if occurred - # Restores previous context - # Ends span -``` - -**Methods:** -| Method | Purpose | -|--------|---------| -| `record_error(exception)` | Record exception with status ERROR | -| `record_response(response)` | Record response content as attribute | -| `record_cancellation()` | Record task cancellation | -| `set_tag_maybe(name, value)` | Set attribute if value is not None | -| `add_baggage(key, value)` | Add baggage to current context | -| `record_attributes(attrs)` | Set multiple attributes at once | - -#### InvokeAgentScope ([invoke_agent_scope.py](../microsoft_agents_a365/observability/core/invoke_agent_scope.py)) - -Traces agent invocation operations (entry point for agent requests): - -```python -from microsoft_agents_a365.observability.core import ( - InvokeAgentScope, - InvokeAgentScopeDetails, - AgentDetails, - Request, -) - -with InvokeAgentScope.start( - request=Request(content="Hello"), - invoke_scope_details=InvokeAgentScopeDetails(endpoint=parsed_url), - agent_details=AgentDetails(agent_id="agent-456", agent_name="MyAgent"), -) as scope: - # Agent processing - scope.record_response("Agent response") -``` - -**Span attributes recorded:** -- Server address and port -- Execution source metadata -- Input/output messages -- Caller details (if provided) - -#### InferenceScope ([inference_scope.py](../microsoft_agents_a365/observability/core/inference_scope.py)) - -Traces LLM/AI model inference calls: - -```python -from microsoft_agents_a365.observability.core import ( - InferenceCallDetails, - InferenceOperationType, - InferenceScope, - Request, - ServiceEndpoint, -) - -with InferenceScope.start( - request=Request(content="Hello"), - details=InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - responseModel="gpt-4-2025-04-14", - cacheReadInputTokens=20, - endpoint=ServiceEndpoint(hostname="models.example.com", port=443), - ), - agent_details=agent_details, -) as scope: - # LLM call - scope.record_input_tokens(100) - scope.record_output_tokens(50) - scope.record_finish_reasons(["stop"]) -``` - -#### ExecuteToolScope ([execute_tool_scope.py](../microsoft_agents_a365/observability/core/execute_tool_scope.py)) - -Traces tool execution operations: - -```python -from microsoft_agents_a365.observability.core import ExecuteToolScope, ToolCallDetails, Request - -with ExecuteToolScope.start( - request=Request(content="search for weather"), - details=ToolCallDetails( - tool_name="search", - tool_arguments={"query": "weather"} - ), - agent_details=agent_details, -) as scope: - # Tool execution - scope.record_response("Tool result") -``` - -### Context Propagation ([middleware/baggage_builder.py](../microsoft_agents_a365/observability/core/middleware/baggage_builder.py)) - -`BaggageBuilder` provides a fluent API for setting OpenTelemetry baggage values: - -```python -from microsoft_agents_a365.observability.core import BaggageBuilder - -# Full builder pattern -with BaggageBuilder() \ - .tenant_id("tenant-123") \ - .agent_id("agent-456") \ - .correlation_id("corr-789") \ - .user_id("user-abc") \ - .session_id("session-xyz") \ - .build(): - # All child spans inherit this baggage - pass - -# Convenience method for common fields -with BaggageBuilder.set_request_context( - tenant_id="tenant-123", - agent_id="agent-456", - correlation_id="corr-789" -): - pass -``` - -**Available baggage setters:** -| Method | Baggage Key | -|--------|-------------| -| `tenant_id(value)` | `tenant_id` | -| `agent_id(value)` | `gen_ai.agent.id` | -| `agent_auid(value)` | `gen_ai.agent.auid` | -| `agent_email(value)` | `gen_ai.agent.upn` | -| `correlation_id(value)` | `correlation_id` | -| `user_id(value)` | `gen_ai.caller.id` | -| `user_name(value)` | `gen_ai.caller.name` | -| `user_email(value)` | `gen_ai.caller.upn` | -| `session_id(value)` | `session_id` | -| `conversation_id(value)` | `gen_ai.conversation.id` | -| `channel_name(value)` | `gen_ai.execution.source.name` | - -### Span Processor ([trace_processor/span_processor.py](../microsoft_agents_a365/observability/core/trace_processor/span_processor.py)) - -Custom `SpanProcessor` that copies baggage entries to span attributes on span start: - -```python -class SpanProcessor(OtelSpanProcessor): - def on_start(self, span, parent_context): - # Copy all baggage entries to span attributes - for key, value in baggage.get_all(parent_context).items(): - span.set_attribute(key, value) -``` - -This ensures that context values set via `BaggageBuilder` are recorded as span attributes. - -### Exporter ([exporters/agent365_exporter.py](../microsoft_agents_a365/observability/core/exporters/agent365_exporter.py)) - -`_Agent365Exporter` exports spans to the Agent365 backend: - -**Export flow:** -1. Partition spans by `(tenant_id, agent_id)` tuple -2. For each partition: - - Resolve endpoint host via the configured domain override or production endpoint - - Build the S2S OTLP URL `/observabilityService/tenants/{tenant_id}/otlp/agents/{agent_id}/traces?api-version=1` - - Resolve an app-only OBS token via `token_resolver(agent_id, tenant_id)` - - Build OTLP-like JSON payload - - POST to the S2S OTLP route -3. Retry transient failures (408, 429, 5xx) up to 3 times with exponential backoff - -The exporter always uses the S2S `/observabilityService` route. The -`use_s2s_endpoint` option is deprecated and ignored, including when `False`. -There is no delegated `/observability` fallback for batch export, request export, -401/403/404 responses, missing tokens, or token acquisition failures. - -`token_resolver` must return an app-only OBS token for the exporting agent and -tenant. It is invoked on every export batch/identity group, so resolvers should -cache and refresh tokens near expiry. Resolvers should request the OBS resource -`/.default` scope (`api://9b975845-388f-4429-889e-eab1ef63949c/.default`) and -validate the token before returning it: reject any `scp` claim and any `idtyp` -other than `app`; when `idtyp` is absent, accept only a non-empty `roles` array -or a non-empty `oid` equal to `sub`; verify `aud` is the OBS resource and the -token is not expired. - -**Configuration via `Agent365ExporterOptions`:** -```python -from microsoft_agents_a365.observability.core.exporters import Agent365ExporterOptions - -options = Agent365ExporterOptions( - cluster_category="prod", - token_resolver=my_token_resolver, - use_s2s_endpoint=False, # Deprecated and ignored; export still uses S2S. - max_queue_size=2048, - scheduled_delay_ms=5000, - exporter_timeout_ms=30000, - max_export_batch_size=512, -) -``` - -## Data Classes - -### InvokeAgentScopeDetails - -```python -@dataclass -class InvokeAgentScopeDetails: - endpoint: ParseResult | None # Parsed URL of the agent endpoint -``` - -### AgentDetails - -```python -@dataclass -class AgentDetails: - agent_id: str | None - agent_name: str | None - agent_description: str | None - agent_auid: str | None # Agent unique identifier - agent_email: str | None # Agent email address - agent_blueprint_id: str | None - tenant_id: str | None - icon_uri: str | None - agent_type: AgentType | str | None # appended last for positional compat -``` - -### UserDetails - -```python -@dataclass -class UserDetails: - user_id: str | None - user_email: str | None - user_name: str | None - caller_client_ip: str | None -``` - -### CallerDetails - -```python -@dataclass -class CallerDetails: - user_details: UserDetails | None - caller_agent_details: AgentDetails | None -``` - -### SpanDetails - -```python -@dataclass -class SpanDetails: - parent_context: Context | None - start_time: int | None - end_time: int | None - span_kind: SpanKind | None -``` - -### InferenceCallDetails - -```python -@dataclass -class InferenceCallDetails: - operationName: InferenceOperationType - model: str | None - providerName: str - inputTokens: int | None - outputTokens: int | None - finishReasons: list[str] | None - thoughtProcess: str | None - endpoint: ServiceEndpoint | None - responseModel: str | None # appended last for positional compat - cacheReadInputTokens: int | None # appended last for positional compat - cacheWriteInputTokens: int | None # appended last for positional compat -``` - -At least one of `model` or `responseModel` must be present (whitespace-only -values are normalized to `None`). The `gen_ai.operation.name` attribute and the -span name prefix are derived from `operationName` via its canonical lowercase -form (`InferenceOperationType.CHAT` -> `chat`, `TEXT_COMPLETION` -> -`text_completion`, `GENERATE_CONTENT` -> `generate_content`), matching the -OpenTelemetry GenAI semantic conventions and the Agent365 model observation -pipeline. Endpoint ports and token counts are emitted as integers. - -### ToolCallDetails - -```python -@dataclass -class ToolCallDetails: - tool_name: str | None - tool_arguments: dict | None - tool_endpoint: str | None - tool_type: ToolType | None -``` - -## Environment Variables - -| Variable | Purpose | Values | -|----------|---------|--------| -| `ENABLE_OBSERVABILITY` | Enable OpenTelemetry tracing | `true`, `false` | -| `ENABLE_A365_OBSERVABILITY` | Enable Agent365-specific tracing | `true`, `false` | -| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable Agent365 backend exporter | `true`, `false` | - -## Design Patterns - -### Singleton Pattern - -`TelemetryManager` uses double-checked locking for thread-safe singleton initialization: - -```python -class TelemetryManager: - _instance = None - _lock = threading.Lock() - - def __new__(cls): - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance -``` - -### Context Manager Pattern - -All scope classes implement `__enter__` and `__exit__` for automatic span lifecycle: - -```python -with InvokeAgentScope.start(...) as scope: - # Span is active - scope.record_response("result") -# Span automatically ends, errors recorded if exception raised -``` - -### Builder Pattern - -`BaggageBuilder` uses method chaining for fluent configuration: - -```python -builder = BaggageBuilder().tenant_id("t").agent_id("a").build() -``` - -## File Structure - -``` -microsoft_agents_a365/observability/core/ -├── __init__.py # Public API exports -├── config.py # TelemetryManager singleton -├── opentelemetry_scope.py # Base scope class -├── invoke_agent_scope.py # Agent invocation tracing -├── inference_scope.py # LLM inference tracing -├── execute_tool_scope.py # Tool execution tracing -├── output_scope.py # Output tracing -├── agent_details.py # AgentDetails dataclass -├── invoke_agent_scope_details.py # InvokeAgentScopeDetails dataclass -├── user_details.py # UserDetails dataclass -├── span_details.py # SpanDetails dataclass -├── inference_call_details.py # InferenceCallDetails dataclass -├── tool_call_details.py # ToolCallDetails dataclass -├── request.py # Request dataclass -├── source_metadata.py # SourceMetadata dataclass -├── inference_operation_type.py # InferenceOperationType enum -├── tool_type.py # ToolType enum -├── constants.py # Attribute key constants -├── utils.py # Utility functions -├── middleware/ -│ ├── __init__.py -│ └── baggage_builder.py # BaggageBuilder and BaggageScope -├── trace_processor/ -│ ├── __init__.py -│ ├── span_processor.py # Custom SpanProcessor -│ └── util.py # Processor utilities -├── exporters/ -│ ├── __init__.py -│ ├── agent365_exporter.py # Agent365 backend exporter -│ ├── agent365_exporter_options.py # Exporter configuration -│ └── utils.py # Exporter utilities -└── models/ - ├── __init__.py - ├── agent_type.py # AgentType enum - ├── caller_details.py # CallerDetails dataclass - └── operation_source.py # OperationSource enum -``` - -## Testing - -Tests are located in `tests/observability/core/`: - -```bash -# Run all observability core tests -pytest tests/observability/core/ -v - -# Run specific test file -pytest tests/observability/core/test_invoke_agent_scope.py -v -``` - -## Dependencies - -- `opentelemetry-api` - OpenTelemetry API interfaces -- `opentelemetry-sdk` - OpenTelemetry SDK implementation -- `requests` - HTTP client for exporter -- `microsoft-agents-a365-runtime` - Endpoint discovery utilities diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py deleted file mode 100644 index cce34580..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py +++ /dev/null @@ -1,143 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Microsoft Agent 365 Python SDK for OpenTelemetry tracing. - -from .agent_details import AgentDetails -from .apply_guardrail_scope import ApplyGuardrailScope -from .config import ( - configure, - get_tracer, - get_tracer_provider, - is_configured, -) -from .execute_tool_scope import ExecuteToolScope -from .exporters.agent365_exporter_options import Agent365ExporterOptions -from .exporters.enriched_span import EnrichedReadableSpan -from .exporters.enriching_span_processor import ( - get_span_enricher, - register_span_enricher, - unregister_span_enricher, -) -from .exporters.spectra_exporter_options import SpectraExporterOptions -from .guardrail_decision_type import GuardrailDecisionType -from .guardrail_details import GuardrailDetails -from .guardrail_finding import GuardrailFinding -from .guardrail_risk_severity import GuardrailRiskSeverity -from .guardrail_target_type import GuardrailTargetType -from .inference_call_details import InferenceCallDetails -from .models.service_endpoint import ServiceEndpoint -from .inference_operation_type import InferenceOperationType -from .inference_scope import InferenceScope -from .invoke_agent_details import InvokeAgentScopeDetails -from .invoke_agent_scope import InvokeAgentScope -from .middleware.baggage_builder import BaggageBuilder -from .models.caller_details import CallerDetails -from .models.messages import ( - BlobPart, - ChatMessage, - FilePart, - FinishReason, - GenericPart, - InputMessages, - InputMessagesParam, - MessagePart, - MessageRole, - Modality, - OutputMessage, - OutputMessages, - OutputMessagesParam, - ReasoningPart, - ServerToolCallPart, - ServerToolCallResponsePart, - TextPart, - ToolCallRequestPart, - ToolCallResponsePart, - UriPart, -) -from .models.response import Response -from .models.user_details import UserDetails -from .opentelemetry_scope import OpenTelemetryScope -from .request import Request -from .channel import Channel -from .span_details import SpanDetails -from .spans_scopes.output_scope import OutputScope -from .tool_call_details import ToolCallDetails -from .tool_type import ToolType -from .trace_processor.span_processor import SpanProcessor -from .utils import extract_context_from_headers, get_traceparent - -__all__ = [ - # Main SDK functions - "configure", - "is_configured", - "get_tracer", - "get_tracer_provider", - # Exporter options - "Agent365ExporterOptions", - "SpectraExporterOptions", - # Span enrichment - "register_span_enricher", - "unregister_span_enricher", - "get_span_enricher", - "EnrichedReadableSpan", - # Span processor - "SpanProcessor", - # Base scope class - "OpenTelemetryScope", - # Specific scope classes - "ApplyGuardrailScope", - "ExecuteToolScope", - "InvokeAgentScope", - "InferenceScope", - "OutputScope", - # Middleware - "BaggageBuilder", - # Data classes - "InvokeAgentScopeDetails", - "AgentDetails", - "CallerDetails", - "UserDetails", - "ToolCallDetails", - "Channel", - "Request", - "Response", - "SpanDetails", - "InferenceCallDetails", - "ServiceEndpoint", - "GuardrailDetails", - "GuardrailFinding", - # Enums - "GuardrailDecisionType", - "GuardrailRiskSeverity", - "GuardrailTargetType", - "InferenceOperationType", - "ToolType", - # OTEL gen-ai message format types - "MessageRole", - "FinishReason", - "Modality", - "TextPart", - "ToolCallRequestPart", - "ToolCallResponsePart", - "ReasoningPart", - "BlobPart", - "FilePart", - "UriPart", - "ServerToolCallPart", - "ServerToolCallResponsePart", - "GenericPart", - "MessagePart", - "ChatMessage", - "OutputMessage", - "InputMessages", - "OutputMessages", - "InputMessagesParam", - "OutputMessagesParam", - # Utility functions - "extract_context_from_headers", - "get_traceparent", -] - -# This is a namespace package -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py deleted file mode 100644 index 0a4e8752..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass -from typing import Optional, Union - -from .models.agent_type import AgentType - - -@dataclass -class AgentDetails: - """Details about an AI agent in the system.""" - - agent_id: str - """The unique identifier for the AI agent.""" - - agent_name: Optional[str] = None - """The human-readable name of the AI agent.""" - - agent_description: Optional[str] = None - """A description of the AI agent's purpose or capabilities.""" - - agentic_user_id: Optional[str] = None - """Agentic User ID for the agent.""" - - agentic_user_email: Optional[str] = None - """Email address for the agentic user.""" - - agent_blueprint_id: Optional[str] = None - """Blueprint/Application ID for the agent.""" - - agent_platform_id: Optional[str] = None - """Platform ID for the agent.""" - - tenant_id: Optional[str] = None - """Tenant ID for the agent.""" - - icon_uri: Optional[str] = None - """Optional icon URI for the agent.""" - - provider_name: Optional[str] = None - """The provider name (e.g., openai, anthropic).""" - - agent_version: Optional[str] = None - """Optional version of the agent (e.g., "1.0.0", "2025-05-01").""" - - # NOTE: Appended after all pre-existing fields (rather than inserted in the - # middle) to preserve positional-argument compatibility for existing callers. - agent_type: Optional[Union[AgentType, str]] = None - """Type classification for the agent. Accepts an `AgentType` enum member or - a raw string; enum members are normalized to their string `.value` when - emitted as a span attribute or baggage entry.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/apply_guardrail_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/apply_guardrail_scope.py deleted file mode 100644 index 59f092dd..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/apply_guardrail_scope.py +++ /dev/null @@ -1,240 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from opentelemetry.trace import SpanKind - -from .agent_details import AgentDetails -from .constants import ( - APPLY_GUARDRAIL_OPERATION_NAME, - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GUARDIAN_ID_KEY, - GUARDIAN_NAME_KEY, - GUARDIAN_PROVIDER_NAME_KEY, - GUARDIAN_VERSION_KEY, - SECURITY_CONTENT_INPUT_HASH_KEY, - SECURITY_CONTENT_INPUT_VALUE_KEY, - SECURITY_CONTENT_MODIFIED_KEY, - SECURITY_CONTENT_OUTPUT_VALUE_KEY, - SECURITY_DECISION_CODE_KEY, - SECURITY_DECISION_REASON_KEY, - SECURITY_DECISION_TYPE_KEY, - SECURITY_EXTERNAL_EVENT_ID_KEY, - SECURITY_FINDING_EVENT_NAME, - SECURITY_POLICY_DECISION_TYPE_KEY, - SECURITY_POLICY_ID_KEY, - SECURITY_POLICY_NAME_KEY, - SECURITY_POLICY_VERSION_KEY, - SECURITY_RISK_CATEGORY_KEY, - SECURITY_RISK_METADATA_KEY, - SECURITY_RISK_SCORE_KEY, - SECURITY_RISK_SEVERITY_KEY, - SECURITY_TARGET_ID_KEY, - SECURITY_TARGET_TYPE_KEY, -) -from .guardrail_decision_type import GuardrailDecisionType -from .guardrail_details import GuardrailDetails -from .guardrail_finding import GuardrailFinding -from .models.user_details import UserDetails -from .opentelemetry_scope import OpenTelemetryScope -from .request import Request -from .span_details import SpanDetails -from .utils import safe_json_dumps - - -class ApplyGuardrailScope(OpenTelemetryScope): - """Provides OpenTelemetry tracing scope for security guardrail evaluation operations. - - Describes a security guardian evaluation. Multiple guardian spans MAY exist under a - single operation span if multiple guardians are chained. - - Guardian spans SHOULD be children of the operation span they are protecting - (e.g., inference or execute_tool spans). - """ - - @staticmethod - def start( - details: GuardrailDetails, - agent_details: AgentDetails, - request: Request | None = None, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ) -> "ApplyGuardrailScope": - """Creates and starts a new scope for guardrail evaluation tracing. - - Args: - details: Details of the guardrail evaluation (target, decision, guardian - info, policy). - agent_details: Information about the agent being guarded. - request: Optional request details for conversation context. - user_details: Optional human user details. - span_details: Optional span configuration (parent context, timing, kind, - span links). - - Returns: - A new ApplyGuardrailScope instance. - """ - return ApplyGuardrailScope(details, agent_details, request, user_details, span_details) - - def __init__( - self, - details: GuardrailDetails, - agent_details: AgentDetails, - request: Request | None = None, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ): - """Initialize the guardrail evaluation scope. - - Args: - details: Details of the guardrail evaluation. - agent_details: Information about the agent being guarded. - request: Optional request details for conversation context. - user_details: Optional human user details. - span_details: Optional span configuration. - """ - resolved_span_details = SpanDetails( - span_kind=( - span_details.span_kind - if span_details and span_details.span_kind - else SpanKind.INTERNAL - ), - parent_context=span_details.parent_context if span_details else None, - start_time=span_details.start_time if span_details else None, - end_time=span_details.end_time if span_details else None, - span_links=span_details.span_links if span_details else None, - ) - - activity_name = self._build_activity_name(details) - - super().__init__( - operation_name=APPLY_GUARDRAIL_OPERATION_NAME, - activity_name=activity_name, - agent_details=agent_details, - span_details=resolved_span_details, - ) - - # Required attributes - self.set_tag_maybe(SECURITY_DECISION_TYPE_KEY, details.decision_type.value) - self.set_tag_maybe(SECURITY_TARGET_TYPE_KEY, details.target_type) - - # Guardian attributes - self.set_tag_maybe(GUARDIAN_ID_KEY, details.guardian_id) - self.set_tag_maybe(GUARDIAN_NAME_KEY, details.guardian_name) - self.set_tag_maybe(GUARDIAN_PROVIDER_NAME_KEY, details.guardian_provider_name) - self.set_tag_maybe(GUARDIAN_VERSION_KEY, details.guardian_version) - - # Target attributes - self.set_tag_maybe(SECURITY_TARGET_ID_KEY, details.target_id) - - # Decision attributes - self.set_tag_maybe(SECURITY_DECISION_REASON_KEY, details.decision_reason) - self.set_tag_maybe(SECURITY_DECISION_CODE_KEY, details.decision_code) - - # Policy attributes - self.set_tag_maybe(SECURITY_POLICY_ID_KEY, details.policy_id) - self.set_tag_maybe(SECURITY_POLICY_NAME_KEY, details.policy_name) - self.set_tag_maybe(SECURITY_POLICY_VERSION_KEY, details.policy_version) - - # Content attributes - self.set_tag_maybe(SECURITY_CONTENT_INPUT_HASH_KEY, details.content_input_hash) - if details.content_modified is not None: - self.set_tag_maybe(SECURITY_CONTENT_MODIFIED_KEY, details.content_modified) - - # Correlation - self.set_tag_maybe(SECURITY_EXTERNAL_EVENT_ID_KEY, details.external_event_id) - - # Request context - if request is not None: - content_value = self._serialize_content(request.content) - self.set_tag_maybe(SECURITY_CONTENT_INPUT_VALUE_KEY, content_value) - self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) - if request.channel is not None: - self.set_tag_maybe(CHANNEL_NAME_KEY, request.channel.name) - self.set_tag_maybe(CHANNEL_LINK_KEY, request.channel.link) - - def record_decision( - self, decision_type: GuardrailDecisionType, reason: str | None = None - ) -> None: - """Records an updated decision on the guardrail span. - - Use this when the guardrail decision is determined after span creation. - - Args: - decision_type: The decision type made by the guardian. - reason: Optional human-readable explanation for the decision. - """ - self.set_tag_maybe(SECURITY_DECISION_TYPE_KEY, decision_type.value) - self.set_tag_maybe(SECURITY_DECISION_REASON_KEY, reason) - - def record_content_output(self, output_value: str) -> None: - """Records the output content value for the guardrail evaluation (opt-in). - - Args: - output_value: The output content after guardrail processing. - """ - self.set_tag_maybe(SECURITY_CONTENT_OUTPUT_VALUE_KEY, output_value) - - def record_finding(self, finding: GuardrailFinding) -> None: - """Records a security finding event on the current span. - - Multiple findings may be recorded per guardrail evaluation. - - Args: - finding: The security finding to record. - - Raises: - ValueError: When finding is None. - """ - if finding is None: - raise ValueError("finding must not be None") - - if self._span is None or not self._is_telemetry_enabled(): - return - - attributes: dict[str, str | float | list[str]] = { - SECURITY_RISK_CATEGORY_KEY: finding.risk_category, - SECURITY_RISK_SEVERITY_KEY: finding.risk_severity, - } - - if finding.policy_decision_type is not None: - attributes[SECURITY_POLICY_DECISION_TYPE_KEY] = finding.policy_decision_type - - if finding.policy_id is not None: - attributes[SECURITY_POLICY_ID_KEY] = finding.policy_id - - if finding.policy_name is not None: - attributes[SECURITY_POLICY_NAME_KEY] = finding.policy_name - - if finding.policy_version is not None: - attributes[SECURITY_POLICY_VERSION_KEY] = finding.policy_version - - if finding.risk_score is not None: - attributes[SECURITY_RISK_SCORE_KEY] = finding.risk_score - - if finding.risk_metadata is not None: - attributes[SECURITY_RISK_METADATA_KEY] = finding.risk_metadata - - self._span.add_event(SECURITY_FINDING_EVENT_NAME, attributes=attributes) - - @staticmethod - def _build_activity_name(details: GuardrailDetails) -> str: - """Build the display name for the span.""" - if details.guardian_name: - return f"{APPLY_GUARDRAIL_OPERATION_NAME} {details.guardian_name} {details.target_type}" - return f"{APPLY_GUARDRAIL_OPERATION_NAME} {details.target_type}" - - @staticmethod - def _serialize_content(content: object) -> str | None: - """Serialize request content to a string suitable for an OTel attribute. - - OTel attributes only accept primitive values or sequences of primitives. - If content is already a string it is returned as-is; otherwise it is - JSON-serialized so that structured InputMessages objects are captured. - """ - if content is None: - return None - if isinstance(content, str): - return content - return safe_json_dumps(content) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/channel.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/channel.py deleted file mode 100644 index 2278292b..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/channel.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Channel class. - -from dataclasses import dataclass - - -@dataclass -class Channel: - """Channel information for agent execution context.""" - - name: str | None = None - link: str | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py deleted file mode 100644 index 3d124a05..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py +++ /dev/null @@ -1,332 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import logging -import threading -from typing import Any, Optional - -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter as GrpcOTLPSpanExporter, -) -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_NAMESPACE, Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import ConsoleSpanExporter - -from .exporters.agent365_exporter import _Agent365Exporter -from .exporters.agent365_exporter_options import Agent365ExporterOptions, TokenResolver -from .exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from .exporters.spectra_exporter_options import SpectraExporterOptions -from .exporters.utils import is_agent365_exporter_enabled -from .trace_processor.span_processor import SpanProcessor - -DEFAULT_LOGGER_NAME = __name__ - - -class TelemetryManager: - """ - Thread-safe singleton manager for telemetry operations. - - This class encapsulates all telemetry state and operations, providing - a clean interface for configuration, flushing, and shutdown without - relying on global variables. - """ - - _instance: Optional["TelemetryManager"] = None - _lock = threading.Lock() - - def __new__(cls) -> "TelemetryManager": - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self): - if not getattr(self, "_initialized", False): - self._tracer_provider: TracerProvider | None = None - self._span_processors: dict[str, Any] = {} - self._logger = logging.getLogger(__name__) - self._initialized = True - - def configure( - self, - service_name: str, - service_namespace: str, - logger_name: str = DEFAULT_LOGGER_NAME, - token_resolver: TokenResolver | None = None, - cluster_category: str = "prod", - exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, - suppress_invoke_agent_input: bool = False, - **kwargs: Any, - ) -> bool: - """ - Configure telemetry. - - :param service_name: The name of the service. - :param service_namespace: The namespace of the service. - :param logger_name: The name of the logger to collect telemetry from. - :param token_resolver: (Deprecated) Callable that returns an app-only OBS token - for a given agent + tenant. Use exporter_options instead. - :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). - Use exporter_options instead. - :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API - export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) - to construct Agent365ExporterOptions from legacy parameters. - :param suppress_invoke_agent_input: If True, suppress input messages for InvokeAgent spans. - :return: True if configuration succeeded, False otherwise. - """ - try: - with self._lock: - return self._configure_internal( - service_name, - service_namespace, - logger_name, - token_resolver, - cluster_category, - exporter_options, - suppress_invoke_agent_input, - **kwargs, - ) - except Exception as e: - self._logger.error(f"❌ Failed to configure telemetry: {e}") - return False - - def _configure_internal( - self, - service_name: str, - service_namespace: str, - logger_name: str, - token_resolver: TokenResolver | None = None, - cluster_category: str = "prod", - exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, - suppress_invoke_agent_input: bool = False, - **kwargs: Any, - ) -> bool: - """Internal configuration method - not thread-safe, must be called with lock.""" - - # Check if a365 observability is already configured - if self._tracer_provider is not None: - self._logger.warning( - "a365 observability already configured. Ignoring repeated configure() call." - ) - return True - - # Create resource with service information - resource = Resource.create( - { - SERVICE_NAMESPACE: service_namespace, - SERVICE_NAME: service_name, - } - ) - - # Check if there's an existing TracerProvider (from app's OTEL setup) - tracer_provider = trace.get_tracer_provider() - - # Determine if we should use existing provider or create new one - # Check if it's a real TracerProvider with a resource (not a proxy/no-op) - if getattr(tracer_provider, "resource", None): - # Use existing provider from application's OTEL setup - self._logger.info( - "Detected existing TracerProvider with resource. " - "Adding a365 observability processors to it." - ) - else: - # Create new TracerProvider with our resource - self._logger.info("Creating new TracerProvider for a365 observability.") - tracer_provider = TracerProvider(resource=resource) - trace.set_tracer_provider(tracer_provider) - - # Store reference - self._tracer_provider = tracer_provider - - # Use exporter_options if provided, otherwise create default options with legacy parameters - if exporter_options is None: - exporter_options = Agent365ExporterOptions( - cluster_category=cluster_category, - token_resolver=token_resolver, - ) - - # Extract configuration for BatchSpanProcessor - batch_processor_kwargs = { - "max_queue_size": exporter_options.max_queue_size, - "schedule_delay_millis": exporter_options.scheduled_delay_ms, - "export_timeout_millis": exporter_options.exporter_timeout_ms, - "max_export_batch_size": exporter_options.max_export_batch_size, - } - - # Type-based exporter dispatch - if isinstance(exporter_options, SpectraExporterOptions): - # Spectra path — OTLP exporter to sidecar - # ENABLE_A365_OBSERVABILITY_EXPORTER is intentionally ignored. - if exporter_options.protocol == "grpc": - exporter = GrpcOTLPSpanExporter( - endpoint=exporter_options.endpoint, - insecure=exporter_options.insecure, - ) - else: - exporter = OTLPSpanExporter( - endpoint=exporter_options.endpoint, - ) - - elif is_agent365_exporter_enabled() and exporter_options.token_resolver is not None: - exporter = _Agent365Exporter( - token_resolver=exporter_options.token_resolver, - cluster_category=exporter_options.cluster_category, - use_s2s_endpoint=exporter_options.use_s2s_endpoint, - max_payload_bytes=exporter_options.max_payload_bytes, - ) - - else: - exporter = ConsoleSpanExporter() - self._logger.warning( - "is_agent365_exporter_enabled() not enabled or token_resolver not set." - " Falling back to console exporter." - ) - - # Add span processors - - # Create _EnrichingBatchSpanProcessor with optimized settings - # This allows extensions to enrich spans before export - batch_processor = _EnrichingBatchSpanProcessor( - exporter, - suppress_invoke_agent_input=suppress_invoke_agent_input, - **batch_processor_kwargs, - ) - agent_processor = SpanProcessor() - - tracer_provider.add_span_processor(batch_processor) - tracer_provider.add_span_processor(agent_processor) - - # Store references for cleanup - self._span_processors["batch"] = batch_processor - self._span_processors["agent"] = agent_processor - - if os.environ.get("ENABLE_OTLP_EXPORTER", "").lower() == "true": - # The OTLPSpanExporter is auto configured from the environment variables - otlp_exporter = OTLPSpanExporter() - tracer_provider.add_span_processor( - _EnrichingBatchSpanProcessor(otlp_exporter, **batch_processor_kwargs) - ) - - # Configure logging if logger_name is provided - if logger_name: - target_logger = logging.getLogger(logger_name) - target_logger.setLevel(logging.INFO) - - return True - - def is_configured(self) -> bool: - """Check if the telemetry manager is configured.""" - return self._tracer_provider is not None - - def get_tracer(self, name: str | None = None, version: str | None = None): - """ - Return an OpenTelemetry Tracer tied to the TracerProvider configured by agent365. - - If the telemetry manager is not configured yet, this returns the default - (no-op) tracer from OpenTelemetry and logs a warning. Callers should prefer - to call `configure(...)` during application startup so the tracer - returned is backed by the configured TracerProvider. - - :param name: Optional tracer name. Defaults to 'microsoft_agents_a365.observability.core' when not provided. - :param version: Optional tracer version. - :return: An OpenTelemetry Tracer instance. - """ - tracer_name = name or DEFAULT_LOGGER_NAME - if self._tracer_provider is None: - # Not configured — return whatever tracer OpenTelemetry provides (no-op) - self._logger.warning( - "agent365 telemetry not configured; returning a no-op tracer for '%s'", tracer_name - ) - return trace.get_tracer(tracer_name, version) - - # Ensure global tracer provider is set (should already be by configure) - try: - return trace.get_tracer(tracer_name, version) - except Exception as e: - self._logger.error("Failed to get tracer '%s': %s", tracer_name, e) - return trace.get_tracer(tracer_name, version) - - def get_tracer_provider(self): - """ - Return the configured TracerProvider instance. - """ - return self._tracer_provider - - -# Singleton instance -_telemetry_manager = TelemetryManager() - - -# Public API functions that delegate to the singleton manager -def configure( - service_name: str, - service_namespace: str, - logger_name: str = DEFAULT_LOGGER_NAME, - token_resolver: TokenResolver | None = None, - cluster_category: str = "prod", - exporter_options: Agent365ExporterOptions | SpectraExporterOptions | None = None, - suppress_invoke_agent_input: bool = False, - **kwargs: Any, -) -> bool: - """ - Configure telemetry with OpenTelemetry. - - :param service_name: The name of the service. - :param service_namespace: The namespace of the service. - :param logger_name: The name of the logger to collect telemetry from. - :param token_resolver: (Deprecated) Callable that returns an app-only OBS token for a given - agent + tenant. Use exporter_options instead. - :param cluster_category: (Deprecated) Environment / cluster category (e.g. "prod"). - Use exporter_options instead. - :param exporter_options: Exporter configuration. Pass Agent365ExporterOptions for A365 API - export, SpectraExporterOptions for Spectra Collector sidecar export, or None (default) - to construct Agent365ExporterOptions from legacy parameters. - :param suppress_invoke_agent_input: If True, suppress input messages for InvokeAgent spans. - :return: True if configuration succeeded, False otherwise. - """ - return _telemetry_manager.configure( - service_name, - service_namespace, - logger_name, - token_resolver, - cluster_category, - exporter_options, - suppress_invoke_agent_input, - **kwargs, - ) - - -def is_configured() -> bool: - """ - Check if telemetry is currently configured. - - :return: True if telemetry is configured and ready to use, False otherwise - """ - return _telemetry_manager.is_configured() - - -def get_tracer(name: str | None = None, version: str | None = None): - """ - Return a tracer tied to the TracerProvider configured by the SDK. - - :param name: Optional tracer name. If omitted, defaults to 'microsoft_agents_a365.observability.core'. - :param version: Optional tracer version. - :return: An OpenTelemetry Tracer (may be a no-op tracer if SDK isn't configured). - """ - return _telemetry_manager.get_tracer(name, version) - - -def get_tracer_provider(): - """ - Return the TracerProvider configured by the SDK. - - :return: Configured OpenTelemetry TracerProvider - """ - return _telemetry_manager.get_tracer_provider() diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py deleted file mode 100644 index 3a174044..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Constants for SDK OpenTelemetry implementation. - -# Span operation names -INVOKE_AGENT_OPERATION_NAME = "invoke_agent" -EXECUTE_TOOL_OPERATION_NAME = "execute_tool" -OUTPUT_MESSAGES_OPERATION_NAME = "output_messages" -APPLY_GUARDRAIL_OPERATION_NAME = "apply_guardrail" -CHAT_OPERATION_NAME = "chat" - -# OpenTelemetry semantic conventions -ERROR_TYPE_KEY = "error.type" -ERROR_MESSAGE_KEY = "error.message" -AZ_NAMESPACE_KEY = "az.namespace" -SERVER_ADDRESS_KEY = "server.address" -SERVER_PORT_KEY = "server.port" -AZURE_RP_NAMESPACE_VALUE = "Microsoft.CognitiveServices" -SOURCE_NAME = "Agent365Sdk" -ENABLE_OPENTELEMETRY_SWITCH = "Azure.Experimental.EnableActivitySource" -TRACE_CONTENTS_SWITCH = "Azure.Experimental.TraceGenAIMessageContent" -TRACE_CONTENTS_ENVIRONMENT_VARIABLE = "AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED" -ENABLE_OBSERVABILITY = "ENABLE_OBSERVABILITY" -ENABLE_A365_OBSERVABILITY_EXPORTER = "ENABLE_A365_OBSERVABILITY_EXPORTER" -ENABLE_A365_OBSERVABILITY = "ENABLE_A365_OBSERVABILITY" - -# GenAI semantic conventions -GEN_AI_CLIENT_OPERATION_DURATION_METRIC_NAME = "gen_ai.client.operation.duration" -GEN_AI_CLIENT_TOKEN_USAGE_METRIC_NAME = "gen_ai.client.token.usage" -GEN_AI_OPERATION_NAME_KEY = "gen_ai.operation.name" -GEN_AI_REQUEST_MAX_TOKENS_KEY = "gen_ai.request.max_tokens" -GEN_AI_REQUEST_MODEL_KEY = "gen_ai.request.model" -GEN_AI_REQUEST_TEMPERATURE_KEY = "gen_ai.request.temperature" -GEN_AI_REQUEST_TOP_P_KEY = "gen_ai.request.top_p" -GEN_AI_RESPONSE_FINISH_REASONS_KEY = "gen_ai.response.finish_reasons" -GEN_AI_RESPONSE_MODEL_KEY = "gen_ai.response.model" -GEN_AI_RESPONSE_ID_KEY = "gen_ai.response.id" -GEN_AI_AGENT_ID_KEY = "gen_ai.agent.id" -GEN_AI_AGENT_TYPE_KEY = "gen_ai.agent.type" -GEN_AI_AGENT_NAME_KEY = "gen_ai.agent.name" -GEN_AI_AGENT_DESCRIPTION_KEY = "gen_ai.agent.description" -GEN_AI_AGENT_VERSION_KEY = "gen_ai.agent.version" -GEN_AI_AGENT_PLATFORM_ID_KEY = "microsoft.a365.agent.platform.id" -GEN_AI_AGENT_THOUGHT_PROCESS_KEY = "microsoft.a365.agent.thought.process" -GEN_AI_CONVERSATION_ID_KEY = "gen_ai.conversation.id" -GEN_AI_CONVERSATION_ITEM_LINK_KEY = "microsoft.conversation.item.link" -GEN_AI_TOKEN_TYPE_KEY = "gen_ai.token.type" -GEN_AI_USAGE_INPUT_TOKENS_KEY = "gen_ai.usage.input_tokens" -GEN_AI_USAGE_OUTPUT_TOKENS_KEY = "gen_ai.usage.output_tokens" -GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY = "gen_ai.usage.cache_read.input_tokens" -GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY = "gen_ai.usage.cache_write.input_tokens" -GEN_AI_CHOICE = "gen_ai.choice" -GEN_AI_PROVIDER_NAME_KEY = "gen_ai.provider.name" - -GEN_AI_SYSTEM_INSTRUCTIONS_KEY = "gen_ai.system_instructions" -GEN_AI_INPUT_MESSAGES_KEY = "gen_ai.input.messages" -GEN_AI_OUTPUT_MESSAGES_KEY = "gen_ai.output.messages" - -# Tool execution constants -GEN_AI_TOOL_CALL_ID_KEY = "gen_ai.tool.call.id" -GEN_AI_TOOL_NAME_KEY = "gen_ai.tool.name" -GEN_AI_TOOL_DESCRIPTION_KEY = "gen_ai.tool.description" -GEN_AI_TOOL_ARGS_KEY = "gen_ai.tool.call.arguments" -GEN_AI_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result" -GEN_AI_TOOL_TYPE_KEY = "gen_ai.tool.type" - -# Human caller dimensions (OTel user.* namespace) -USER_ID_KEY = "user.id" -USER_NAME_KEY = "user.name" -USER_EMAIL_KEY = "user.email" -GEN_AI_CALLER_CLIENT_IP_KEY = "client.address" - -# Agent to Agent caller agent dimensions -GEN_AI_CALLER_AGENT_USER_ID_KEY = "microsoft.a365.caller.agent.user.id" -GEN_AI_CALLER_AGENT_EMAIL_KEY = "microsoft.a365.caller.agent.user.email" -GEN_AI_CALLER_AGENT_NAME_KEY = "microsoft.a365.caller.agent.name" -GEN_AI_CALLER_AGENT_ID_KEY = "microsoft.a365.caller.agent.id" -GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = "microsoft.a365.caller.agent.blueprint.id" -GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY = "microsoft.a365.caller.agent.platform.id" -GEN_AI_CALLER_AGENT_VERSION_KEY = "microsoft.a365.caller.agent.version" - -# Agent-specific dimensions -AGENT_ID_KEY = "gen_ai.agent.id" -GEN_AI_TASK_ID_KEY = "gen_ai.task.id" -SESSION_ID_KEY = "microsoft.session.id" -SESSION_DESCRIPTION_KEY = "microsoft.session.description" -GEN_AI_ICON_URI_KEY = "gen_ai.agent365.icon_uri" -TENANT_ID_KEY = "microsoft.tenant.id" - -# Baggage keys -GEN_AI_AGENT_AUID_KEY = "microsoft.agent.user.id" -GEN_AI_AGENT_EMAIL_KEY = "microsoft.agent.user.email" -GEN_AI_AGENT_BLUEPRINT_ID_KEY = "microsoft.a365.agent.blueprint.id" - -# Error type constants -ERROR_TYPE_CANCELLED = "TaskCanceledException" - -# Execution context dimensions -GEN_AI_EXECUTION_PAYLOAD_KEY = "gen_ai.execution.payload" - -# Channel dimensions -CHANNEL_NAME_KEY = "microsoft.channel.name" -CHANNEL_LINK_KEY = "microsoft.channel.link" - -# Custom parent id and parent name key -CUSTOM_PARENT_SPAN_ID_KEY = "custom.parent.span.id" -CUSTOM_SPAN_NAME_KEY = "custom.span.name" - -# Service attributes -SERVICE_NAME_KEY = "service.name" - -# Telemetry SDK attributes -TELEMETRY_SDK_NAME_KEY = "telemetry.sdk.name" -TELEMETRY_SDK_LANGUAGE_KEY = "telemetry.sdk.language" -TELEMETRY_SDK_VERSION_KEY = "telemetry.sdk.version" -TELEMETRY_SDK_NAME_VALUE = "A365ObservabilitySDK" -TELEMETRY_SDK_LANGUAGE_VALUE = "python" - -# Guardian attributes -GUARDIAN_ID_KEY = "microsoft.guardian.id" -GUARDIAN_NAME_KEY = "microsoft.guardian.name" -GUARDIAN_PROVIDER_NAME_KEY = "microsoft.guardian.provider.name" -GUARDIAN_VERSION_KEY = "microsoft.guardian.version" - -# Security attributes -SECURITY_DECISION_TYPE_KEY = "microsoft.security.decision.type" -SECURITY_DECISION_REASON_KEY = "microsoft.security.decision.reason" -SECURITY_DECISION_CODE_KEY = "microsoft.security.decision.code" -SECURITY_TARGET_TYPE_KEY = "microsoft.security.target.type" -SECURITY_TARGET_ID_KEY = "microsoft.security.target.id" -SECURITY_POLICY_ID_KEY = "microsoft.security.policy.id" -SECURITY_POLICY_NAME_KEY = "microsoft.security.policy.name" -SECURITY_POLICY_VERSION_KEY = "microsoft.security.policy.version" -SECURITY_CONTENT_INPUT_HASH_KEY = "microsoft.security.content.input.hash" -SECURITY_CONTENT_MODIFIED_KEY = "microsoft.security.content.modified" -SECURITY_EXTERNAL_EVENT_ID_KEY = "microsoft.security.external_event_id" -SECURITY_CONTENT_INPUT_VALUE_KEY = "microsoft.security.content.input.value" -SECURITY_CONTENT_OUTPUT_VALUE_KEY = "microsoft.security.content.output.value" - -# Security finding event -SECURITY_FINDING_EVENT_NAME = "microsoft.security.finding" -SECURITY_RISK_CATEGORY_KEY = "microsoft.security.risk.category" -SECURITY_RISK_SEVERITY_KEY = "microsoft.security.risk.severity" -SECURITY_RISK_SCORE_KEY = "microsoft.security.risk.score" -SECURITY_RISK_METADATA_KEY = "microsoft.security.risk.metadata" -SECURITY_POLICY_DECISION_TYPE_KEY = "microsoft.security.policy.decision.type" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py deleted file mode 100644 index 6680a310..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from opentelemetry.trace import SpanKind - -from .agent_details import AgentDetails -from .constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_CALLER_CLIENT_IP_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_TOOL_ARGS_KEY, - GEN_AI_TOOL_CALL_ID_KEY, - GEN_AI_TOOL_CALL_RESULT_KEY, - GEN_AI_TOOL_DESCRIPTION_KEY, - GEN_AI_TOOL_NAME_KEY, - GEN_AI_TOOL_TYPE_KEY, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) -from .utils import safe_json_dumps, validate_and_normalize_ip -from .models.user_details import UserDetails -from .opentelemetry_scope import OpenTelemetryScope -from .request import Request -from .span_details import SpanDetails -from .tool_call_details import ToolCallDetails - - -class ExecuteToolScope(OpenTelemetryScope): - """Provides OpenTelemetry tracing scope for AI tool execution operations.""" - - @staticmethod - def start( - request: Request, - details: ToolCallDetails, - agent_details: AgentDetails, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ) -> "ExecuteToolScope": - """Creates and starts a new scope for tool execution tracing. - - Args: - request: Request details for the tool execution - details: The details of the tool call - agent_details: The details of the agent making the call - user_details: Optional human user details - span_details: Optional span configuration (parent context, timing, kind) - - Returns: - A new ExecuteToolScope instance - """ - return ExecuteToolScope( - request, - details, - agent_details, - user_details, - span_details, - ) - - def __init__( - self, - request: Request, - details: ToolCallDetails, - agent_details: AgentDetails, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ): - """Initialize the tool execution scope. - - Args: - request: Request details for the tool execution - details: The details of the tool call - agent_details: The details of the agent making the call - user_details: Optional human user details - span_details: Optional span configuration (parent context, timing, kind) - """ - # spanKind defaults to INTERNAL; allow override via span_details - resolved_span_details = ( - SpanDetails( - span_kind=span_details.span_kind - if span_details and span_details.span_kind - else SpanKind.INTERNAL, - parent_context=span_details.parent_context if span_details else None, - start_time=span_details.start_time if span_details else None, - end_time=span_details.end_time if span_details else None, - span_links=span_details.span_links if span_details else None, - ) - if span_details - else SpanDetails(span_kind=SpanKind.INTERNAL) - ) - - super().__init__( - operation_name=EXECUTE_TOOL_OPERATION_NAME, - activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}", - agent_details=agent_details, - span_details=resolved_span_details, - ) - - # Extract details - tool_name = details.tool_name - arguments = details.arguments - tool_call_id = details.tool_call_id - description = details.description - tool_type = details.tool_type - endpoint = details.endpoint - - self.set_tag_maybe(GEN_AI_TOOL_NAME_KEY, tool_name) - if arguments is not None: - serialized = safe_json_dumps(arguments) if isinstance(arguments, dict) else arguments - self.set_tag_maybe(GEN_AI_TOOL_ARGS_KEY, serialized) - self.set_tag_maybe(GEN_AI_TOOL_TYPE_KEY, tool_type) - self.set_tag_maybe(GEN_AI_TOOL_CALL_ID_KEY, tool_call_id) - self.set_tag_maybe(GEN_AI_TOOL_DESCRIPTION_KEY, description) - self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) - - if endpoint: - self.set_tag_maybe(SERVER_ADDRESS_KEY, endpoint.hostname) - if endpoint.port and endpoint.port != 443: - self.set_tag_maybe(SERVER_PORT_KEY, endpoint.port) - - # Set request metadata if provided - if request.channel: - self.set_tag_maybe(CHANNEL_NAME_KEY, request.channel.name) - self.set_tag_maybe(CHANNEL_LINK_KEY, request.channel.link) - - # Set user details if provided - if user_details: - self.set_tag_maybe(USER_ID_KEY, user_details.user_id) - self.set_tag_maybe(USER_EMAIL_KEY, user_details.user_email) - self.set_tag_maybe(USER_NAME_KEY, user_details.user_name) - self.set_tag_maybe( - GEN_AI_CALLER_CLIENT_IP_KEY, - validate_and_normalize_ip(user_details.user_client_ip), - ) - - def record_response(self, result: dict[str, object] | str) -> None: - """Record the tool call result for telemetry tracking. - - Per OTEL spec, the result is expected to be an object. If a string - is provided, it is recorded as-is (JSON string fallback). If a dict - is provided, it is serialized to JSON. - - Args: - result: Tool call result as a structured dict or JSON string - """ - serialized = safe_json_dumps(result) if isinstance(result, dict) else result - self.set_tag_maybe(GEN_AI_TOOL_CALL_RESULT_KEY, serialized) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py deleted file mode 100644 index 69cdb993..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from .agent365_exporter_options import Agent365ExporterOptions, TokenResolver -from .spectra_exporter_options import SpectraExporterOptions - -# Agent365Exporter is not exported intentionally. -# It should only be used internally by the observability core module. -__all__ = ["Agent365ExporterOptions", "SpectraExporterOptions", "TokenResolver"] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py deleted file mode 100644 index 0313c023..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ /dev/null @@ -1,428 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# pip install opentelemetry-sdk opentelemetry-api requests - -from __future__ import annotations - -import asyncio -import inspect -import json -import logging -import threading -import time -from collections.abc import Awaitable, Sequence -from typing import Any, cast, final - -import requests -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult -from opentelemetry.trace import StatusCode - -from .agent365_exporter_options import TokenResolver -from .utils import ( - DEFAULT_MAX_PAYLOAD_BYTES, - build_export_url, - chunk_by_size, - estimate_span_bytes, - get_validated_domain_override, - hex_span_id, - hex_trace_id, - kind_name, - parse_retry_after, - filter_and_partition_by_identity, - status_name, - truncate_span, -) - -# ---- Exporter --------------------------------------------------------------- - -# Hardcoded constants - not configurable -DEFAULT_HTTP_TIMEOUT_SECONDS = 30.0 -DEFAULT_MAX_RETRIES = 3 -DEFAULT_ENDPOINT_URL = "https://agent365.svc.cloud.microsoft" - -# Create logger for this module - inherits from 'microsoft_agents_a365.observability.core' -logger = logging.getLogger(__name__) - - -async def _await_token(awaitable: Awaitable[str | None]) -> str | None: - return await awaitable - - -@final -class _Agent365Exporter(SpanExporter): - """ - Agent 365 span exporter for Agent 365: - * Partitions spans by (tenantId, agentId) - * Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans - * POSTs per group to the S2S /observabilityService OTLP route. - * Adds an app-only authorization token via token_resolver(agentId, tenantId) - """ - - def __init__( - self, - token_resolver: TokenResolver, - cluster_category: str = "prod", - use_s2s_endpoint: bool = False, - max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES, - ): - if token_resolver is None: - raise ValueError("token_resolver must be provided.") - self._session = requests.Session() - self._closed = False - self._lock = threading.Lock() - self._token_resolver = token_resolver - self._cluster_category = cluster_category - self._use_s2s_endpoint = use_s2s_endpoint - self._max_payload_bytes = max_payload_bytes - # Read domain override once at initialization - self._domain_override = get_validated_domain_override() - - # ------------- SpanExporter API ----------------- - - def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: - if self._closed: - return SpanExportResult.FAILURE - - try: - groups = filter_and_partition_by_identity(spans) - if not groups: - # No eligible genAI spans to export after filtering/partitioning; treat as success - logger.info("No eligible genAI spans to export; nothing exported.") - return SpanExportResult.SUCCESS - - # Log number of groups and total span count - total_spans = sum(len(activities) for activities in groups.values()) - logger.debug( - f"Found {len(groups)} identity groups with {total_spans} total spans to export" - ) - - any_failure = False - for (tenant_id, agent_id), activities in groups.items(): - # Map and truncate spans first, then chunk by estimated byte size - mapped_spans = self._map_and_truncate_spans(activities) - resource_attrs = self._get_resource_attributes(activities) - chunks = chunk_by_size( - mapped_spans, - lambda ms: estimate_span_bytes(ms[0]), - self._max_payload_bytes, - ) - - if len(chunks) > 1: - # Logged at DEBUG to avoid leaking tenant/agent IDs in production logs. - logger.debug( - f"Split {len(activities)} spans into {len(chunks)} chunks " - f"for tenantId: {tenant_id}, agentId: {agent_id}" - ) - - # Resolve endpoint: domain override > default URL - if self._domain_override: - endpoint = self._domain_override - else: - endpoint = DEFAULT_ENDPOINT_URL - - url = build_export_url(endpoint, agent_id, tenant_id, self._use_s2s_endpoint) - - # Log endpoint details at DEBUG to avoid leaking IDs in production logs - logger.debug( - f"Exporting {len(activities)} spans to endpoint: {url} " - f"(tenant: {tenant_id}, agent: {agent_id})" - ) - - headers = {"content-type": "application/json"} - try: - token = self._resolve_token(agent_id, tenant_id) - if token is not None and token.strip(): - # Warn if sending bearer token over non-HTTPS connection - if not url.lower().startswith("https://"): - logger.warning( - "Bearer token is being sent over a non-HTTPS connection. " - "This may expose credentials in transit." - ) - headers["authorization"] = f"Bearer {token}" - logger.debug( - f"App-only OBS token resolved successfully for agent {agent_id}" - ) - else: - logger.error( - f"No app-only OBS token returned for agent {agent_id}, " - f"tenant {tenant_id}; export request will not be sent" - ) - any_failure = True - continue - except Exception as e: - # If token resolution fails, treat as failure for this group - logger.error( - f"App-only OBS token resolution failed for agent {agent_id}, " - f"tenant {tenant_id}: {e}" - ) - any_failure = True - continue - - # Send each chunk (all-or-nothing: fail group on first chunk failure) - group_failed = False - for i, chunk in enumerate(chunks): - payload = self._build_envelope(chunk, resource_attrs) - body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) - body_bytes = len(body.encode("utf-8")) - logger.debug( - f"Sending chunk {i + 1} of {len(chunks)} " - f"({len(chunk)} spans, {body_bytes} bytes)" - ) - # Defensive check: the estimator covers per-span content but not - # envelope overhead (resource attributes, scope wrappers). Warn if - # the assembled body exceeds the configured limit so operators can - # observe estimator drift before the server starts rejecting requests. - if body_bytes > self._max_payload_bytes: - logger.warning( - f"Chunk {i + 1} of {len(chunks)} body size ({body_bytes} bytes) " - f"exceeds max_payload_bytes ({self._max_payload_bytes}); " - "estimator may be under-counting envelope overhead. " - f"Tenant: {tenant_id}, agent: {agent_id}, spans: {len(chunk)}." - ) - - ok = self._post_with_retries(url, body, headers) - if not ok: - logger.error( - f"Chunk {i + 1} of {len(chunks)} failed for " - f"tenant {tenant_id}, agent {agent_id}" - ) - any_failure = True - group_failed = True - break - - if group_failed: - continue - - return SpanExportResult.FAILURE if any_failure else SpanExportResult.SUCCESS - - except Exception as e: - # Exporters should not raise; signal failure. - logger.error(f"Export failed with exception: {e}") - return SpanExportResult.FAILURE - - def shutdown(self) -> None: - with self._lock: - if self._closed: - return - self._closed = True - try: - self._session.close() - except Exception: - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - return True - - # ------------- HTTP helper ---------------------- - - @staticmethod - def _truncate_text(text: str, max_length: int) -> str: - """Truncate text to a maximum length, adding '...' if truncated.""" - if len(text) > max_length: - return text[:max_length] + "..." - return text - - def _post_with_retries(self, url: str, body: str, headers: dict[str, str]) -> bool: - for attempt in range(DEFAULT_MAX_RETRIES + 1): - try: - resp = self._session.post( - url, - data=body.encode("utf-8"), - headers=headers, - timeout=DEFAULT_HTTP_TIMEOUT_SECONDS, - ) - - # Extract correlation ID from response headers for logging - correlation_id = ( - resp.headers.get("x-ms-correlation-id") - or resp.headers.get("request-id") - or "N/A" - ) - - # 2xx => success - if 200 <= resp.status_code < 300: - logger.debug( - f"HTTP {resp.status_code} success on attempt {attempt + 1}. " - f"Correlation ID: {correlation_id}. " - f"Response: {self._truncate_text(resp.text, 200)}" - ) - return True - - # Log non-success responses - response_text = self._truncate_text(resp.text, 500) - - # Retry transient - if resp.status_code in (408, 429) or 500 <= resp.status_code < 600: - # Respect Retry-After header for 429 responses - retry_after = parse_retry_after(resp.headers) - if attempt < DEFAULT_MAX_RETRIES: - if retry_after is not None: - time.sleep(min(retry_after, 60.0)) - else: - # Exponential backoff with base 0.5s - time.sleep(0.5 * (2**attempt)) - continue - # Final attempt failed - logger.error( - f"HTTP {resp.status_code} final failure after " - f"{DEFAULT_MAX_RETRIES + 1} attempts. " - f"Correlation ID: {correlation_id}. " - f"Response: {response_text}" - ) - else: - # Non-retryable error - logger.error( - f"HTTP {resp.status_code} non-retryable error. " - f"Correlation ID: {correlation_id}. " - f"Response: {response_text}" - ) - return False - - except requests.RequestException as e: - if attempt < DEFAULT_MAX_RETRIES: - # Exponential backoff with base 0.5s - time.sleep(0.5 * (2**attempt)) - continue - # Final attempt failed - logger.error(f"Request failed after {DEFAULT_MAX_RETRIES + 1} attempts: {e}") - return False - return False - - def _resolve_token(self, agent_id: str, tenant_id: str) -> str | None: - token = self._token_resolver(agent_id, tenant_id) - if inspect.isawaitable(token): - try: - asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(_await_token(cast(Awaitable[str | None], token))) - if inspect.iscoroutine(token): - token.close() - elif isinstance(token, asyncio.Future): - token.cancel() - raise RuntimeError( - "Agent365Exporter cannot await an async token_resolver while running " - "inside an active event loop; use a synchronous cached resolver or refresh " - "the app-only OBS token before export." - ) - return token - - # ------------- Payload mapping ------------------ - - def _map_and_truncate_spans( - self, spans: Sequence[ReadableSpan] - ) -> list[tuple[dict[str, Any], str, str | None]]: - """Map ReadableSpans to OTLP dicts and apply per-span truncation. - - Returns a list of (mapped_span, scope_name, scope_version) tuples so - that envelope grouping by instrumentation scope can be performed - efficiently after byte-size chunking. - """ - result: list[tuple[dict[str, Any], str, str | None]] = [] - for sp in spans: - scope = sp.instrumentation_scope - scope_name = scope.name if scope is not None else "unknown" - scope_version = scope.version if scope is not None else None - result.append((self._map_span(sp), scope_name, scope_version)) - return result - - @staticmethod - def _get_resource_attributes(spans: Sequence[ReadableSpan]) -> dict[str, Any]: - """Extract resource attributes from the first span in the batch.""" - if spans: - return dict(getattr(spans[0].resource, "attributes", {}) or {}) - return {} - - def _build_envelope( - self, - mapped_spans: Sequence[tuple[dict[str, Any], str, str | None]], - resource_attrs: dict[str, Any], - ) -> dict[str, Any]: - """Build an OTLP export request envelope from pre-mapped spans.""" - scope_map: dict[tuple[str, str | None], list[dict[str, Any]]] = {} - for mapped_span, scope_name, scope_version in mapped_spans: - scope_map.setdefault((scope_name, scope_version), []).append(mapped_span) - - scope_spans: list[dict[str, Any]] = [ - { - "scope": {"name": name, "version": version}, - "spans": spans, - } - for (name, version), spans in scope_map.items() - ] - - return { - "resourceSpans": [ - { - "resource": {"attributes": resource_attrs or None}, - "scopeSpans": scope_spans, - } - ] - } - - def _map_span(self, sp: ReadableSpan) -> dict[str, Any]: - ctx = sp.get_span_context() - - parent_span_id = None - if sp.parent is not None and sp.parent.span_id != 0: - parent_span_id = hex_span_id(sp.parent.span_id) - - # attributes - attrs = dict(sp.attributes or {}) - - # events - events = [] - for ev in sp.events: - ev_attrs = dict(ev.attributes or {}) if ev.attributes else None - events.append( - { - "timeUnixNano": ev.timestamp, # already ns - "name": ev.name, - "attributes": ev_attrs, - } - ) - if not events: - events = None - - # links - links = [] - for ln in sp.links or []: - ln_attrs = dict(ln.attributes or {}) if ln.attributes else None - links.append( - { - "traceId": hex_trace_id(ln.context.trace_id), - "spanId": hex_span_id(ln.context.span_id), - "attributes": ln_attrs, - } - ) - if not links: - links = None - - # status - status_code = sp.status.status_code if sp.status else StatusCode.UNSET - status = { - "code": status_name(status_code), - "message": getattr(sp.status, "description", "") or "", - } - - # times are ns in ReadableSpan - start_ns = sp.start_time - end_ns = sp.end_time - - span_dict = { - "traceId": hex_trace_id(ctx.trace_id), - "spanId": hex_span_id(ctx.span_id), - "parentSpanId": parent_span_id, - "name": sp.name, - "kind": kind_name(sp.kind), - "startTimeUnixNano": start_ns, - "endTimeUnixNano": end_ns, - "attributes": attrs or None, - "events": events, - "links": links, - "status": status, - } - - # Apply truncation if needed - return truncate_span(span_dict) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py deleted file mode 100644 index e951968a..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from typing import Awaitable, Callable, Optional - -from .utils import DEFAULT_MAX_PAYLOAD_BYTES - -TokenResolver = Callable[[str, str], str | Awaitable[str | None] | None] - - -class Agent365ExporterOptions: - """ - Configuration for Agent365Exporter. - Only cluster_category and token_resolver are required for core operation. - """ - - def __init__( - self, - cluster_category: str = "prod", - token_resolver: Optional[TokenResolver] = None, - use_s2s_endpoint: bool = False, - max_queue_size: int = 2048, - scheduled_delay_ms: int = 5000, - exporter_timeout_ms: int = 30000, - max_export_batch_size: int = 512, - max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES, - ): - """ - Args: - cluster_category: Cluster region argument. Defaults to 'prod'. - token_resolver: Callable that resolves an app-only OBS token (REQUIRED when the - Agent 365 exporter is enabled). It is invoked for each export batch/identity - group, so implementations should cache and refresh tokens near expiry. - use_s2s_endpoint: Deprecated compatibility option. Ignored; export always uses - the S2S /observabilityService OTLP route, even when this is False. - max_queue_size: Maximum queue size for the batch processor. Default is 2048. - scheduled_delay_ms: Delay between export batches (ms). Default is 5000. - exporter_timeout_ms: Timeout for the export operation (ms). Default is 30000. - max_export_batch_size: Maximum batch size for export operations. Default is 512. - max_payload_bytes: Upper bound on HTTP request body size in bytes. The exporter - splits per-identity batches into sub-batches whose estimated size stays under - this limit, providing headroom under the A365 1 MB server limit. Default is - 900_000 (~100 KB headroom for estimator error and JSON envelope overhead). - """ - self.cluster_category = cluster_category - self.token_resolver = token_resolver - self.use_s2s_endpoint = use_s2s_endpoint - self.max_queue_size = max_queue_size - self.scheduled_delay_ms = scheduled_delay_ms - self.exporter_timeout_ms = exporter_timeout_ms - self.max_export_batch_size = max_export_batch_size - self.max_payload_bytes = max_payload_bytes diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py deleted file mode 100644 index 678e4000..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriched_span.py +++ /dev/null @@ -1,169 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Enriched ReadableSpan wrapper for adding attributes to immutable spans.""" - -import json -from typing import Any - -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.util import types - - -class EnrichedReadableSpan(ReadableSpan): - """ - Wrapper to add attributes to an immutable ReadableSpan. - - Since ReadableSpan is immutable after a span ends, this wrapper allows - extensions to add additional attributes before export without modifying - the original span. - """ - - def __init__( - self, - span: ReadableSpan, - extra_attributes: dict, - excluded_attribute_keys: set[str] | None = None, - ): - """ - Initialize the enriched span wrapper. - - Args: - span: The original ReadableSpan to wrap. - extra_attributes: Additional attributes to merge with the original. - excluded_attribute_keys: Attribute keys to remove after merging. - """ - self._span = span - self._extra_attributes = extra_attributes - self._excluded_attribute_keys = excluded_attribute_keys or set() - - @property - def attributes(self) -> types.Attributes: - """Return merged attributes from original span and extra attributes.""" - original = dict(self._span.attributes or {}) - original.update(self._extra_attributes) - for key in self._excluded_attribute_keys: - original.pop(key, None) - return original - - @property - def name(self): - """Return the span name.""" - return self._span.name - - @property - def context(self): - """Return the span context.""" - return self._span.get_span_context() - - @property - def parent(self): - """Return the parent span context.""" - return self._span.parent - - @property - def start_time(self): - """Return the span start time.""" - return self._span.start_time - - @property - def end_time(self): - """Return the span end time.""" - return self._span.end_time - - @property - def status(self): - """Return the span status.""" - return self._span.status - - @property - def kind(self): - """Return the span kind.""" - return self._span.kind - - @property - def events(self): - """Return the span events.""" - return self._span.events - - @property - def links(self): - """Return the span links.""" - return self._span.links - - @property - def resource(self): - """Return the span resource.""" - return self._span.resource - - @property - def instrumentation_scope(self): - """Return the instrumentation scope.""" - return self._span.instrumentation_scope - - def to_json(self, indent: int | None = 4) -> str: - """ - Convert span to JSON string with enriched attributes. - - Args: - indent: JSON indentation level. - - Returns: - JSON string representation of the span. - """ - # Build the JSON dict manually to include enriched attributes - return json.dumps( - { - "name": self.name, - "context": { - "trace_id": f"0x{self.context.trace_id:032x}", - "span_id": f"0x{self.context.span_id:016x}", - "trace_state": str(self.context.trace_state), - } - if self.context - else None, - "kind": str(self.kind), - "parent_id": f"0x{self.parent.span_id:016x}" if self.parent else None, - "start_time": self._format_time(self.start_time), - "end_time": self._format_time(self.end_time), - "status": { - "status_code": str(self.status.status_code), - "description": self.status.description, - } - if self.status - else None, - "attributes": dict(self.attributes) if self.attributes else None, - "events": [self._format_event(e) for e in self.events] if self.events else None, - "links": [self._format_link(lnk) for lnk in self.links] if self.links else None, - "resource": dict(self.resource.attributes) if self.resource else None, - }, - indent=indent, - ) - - def _format_time(self, time_ns: int | None) -> str | None: - """Format nanosecond timestamp to ISO string.""" - if time_ns is None: - return None - from datetime import datetime, timezone - - return datetime.fromtimestamp(time_ns / 1e9, tz=timezone.utc).isoformat() - - def _format_event(self, event: Any) -> dict: - """Format a span event.""" - return { - "name": event.name, - "timestamp": self._format_time(event.timestamp), - "attributes": dict(event.attributes) if event.attributes else None, - } - - def _format_link(self, link: Any) -> dict: - """Format a span link.""" - return { - "context": { - "trace_id": f"0x{link.context.trace_id:032x}", - "span_id": f"0x{link.context.span_id:016x}", - } - if link.context - else None, - "attributes": dict(link.attributes) if link.attributes else None, - } diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py deleted file mode 100644 index b324b0d8..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/enriching_span_processor.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Span enrichment support for the Agent365 exporter pipeline.""" - -import logging -import threading -from collections.abc import Callable - -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -from ..constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, -) -from .enriched_span import EnrichedReadableSpan - -logger = logging.getLogger(__name__) - -# Single span enricher - only one platform instrumentor should be active at a time -_span_enricher: Callable[[ReadableSpan], ReadableSpan] | None = None -_enricher_lock = threading.Lock() - - -def register_span_enricher(enricher: Callable[[ReadableSpan], ReadableSpan]) -> None: - """Register the span enricher for the active platform instrumentor. - - Only one enricher can be registered at a time since auto-instrumentation - is platform-specific (Semantic Kernel, LangChain, or OpenAI Agents). - - Args: - enricher: Function that takes a ReadableSpan and returns an enriched span. - - Raises: - RuntimeError: If an enricher is already registered. - """ - global _span_enricher - with _enricher_lock: - if _span_enricher is not None: - raise RuntimeError( - "A span enricher is already registered. " - "Only one platform instrumentor can be active at a time." - ) - _span_enricher = enricher - logger.debug("Span enricher registered: %s", enricher.__name__) - - -def unregister_span_enricher() -> None: - """Unregister the current span enricher. - - Called during uninstrumentation to clean up. - """ - global _span_enricher - with _enricher_lock: - if _span_enricher is not None: - logger.debug("Span enricher unregistered: %s", _span_enricher.__name__) - _span_enricher = None - - -def get_span_enricher() -> Callable[[ReadableSpan], ReadableSpan] | None: - """Get the currently registered span enricher. - - Returns: - The registered enricher function, or None if no enricher is registered. - """ - with _enricher_lock: - return _span_enricher - - -class _EnrichingBatchSpanProcessor(BatchSpanProcessor): - """BatchSpanProcessor that applies the registered enricher before batching.""" - - def __init__( - self, - *args: object, - suppress_invoke_agent_input: bool = False, - **kwargs: object, - ): - super().__init__(*args, **kwargs) - self._suppress_invoke_agent_input = suppress_invoke_agent_input - - def on_end(self, span: ReadableSpan) -> None: - """Apply the span enricher and pass to parent for batching. - - Args: - span: The span that has ended. - """ - enriched_span = span - - enricher = get_span_enricher() - if enricher is not None: - try: - enriched_span = enricher(span) - except Exception: - logger.exception( - "Span enricher %s raised an exception, using original span", - enricher.__name__, - ) - - # Apply input message suppression for InvokeAgent spans - if self._suppress_invoke_agent_input: - attrs = enriched_span.attributes or {} - operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY) - if ( - enriched_span.name.startswith(INVOKE_AGENT_OPERATION_NAME) - and operation_name == INVOKE_AGENT_OPERATION_NAME - ): - enriched_span = EnrichedReadableSpan( - enriched_span, - extra_attributes={}, - excluded_attribute_keys={GEN_AI_INPUT_MESSAGES_KEY}, - ) - - super().on_end(enriched_span) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py deleted file mode 100644 index 826076a3..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/spectra_exporter_options.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from typing import Literal - - -class SpectraExporterOptions: - """ - Configuration for exporting traces to a Spectra Collector sidecar via OTLP. - - Spectra Collector is deployed as a Kubernetes sidecar that accepts - standard OTLP telemetry on localhost. Defaults are tuned for this - deployment topology — most consumers should not need to override them. - - Note: Batch processor fields (max_queue_size, scheduled_delay_ms, etc.) - are duplicated from Agent365ExporterOptions intentionally — these two - options classes have no shared base class per design decision C4. - """ - - _DEFAULT_GRPC_ENDPOINT = "http://localhost:4317" - _DEFAULT_HTTP_ENDPOINT = "http://localhost:4318" - - def __init__( - self, - endpoint: str | None = None, - protocol: Literal["grpc", "http"] = "grpc", - insecure: bool = True, - max_queue_size: int = 2048, - scheduled_delay_ms: int = 5000, - exporter_timeout_ms: int = 30000, - max_export_batch_size: int = 512, - ): - """ - Args: - endpoint: Spectra sidecar OTLP endpoint. Defaults to - http://localhost:4317 for gRPC or http://localhost:4318 for HTTP. - protocol: OTLP protocol — "grpc" or "http". Default: grpc. - insecure: Use insecure (no TLS) connection. Default: True (localhost sidecar). - max_queue_size: Batch processor queue size. Default: 2048. - scheduled_delay_ms: Export interval in milliseconds. Default: 5000. - exporter_timeout_ms: Export timeout in milliseconds. Default: 30000. - max_export_batch_size: Max spans per export batch. Default: 512. - """ - if protocol not in ("grpc", "http"): - raise ValueError(f"protocol must be 'grpc' or 'http', got '{protocol}'") - if endpoint is None: - endpoint = ( - self._DEFAULT_GRPC_ENDPOINT if protocol == "grpc" else self._DEFAULT_HTTP_ENDPOINT - ) - self.endpoint = endpoint - self.protocol = protocol - self.insecure = insecure - self.max_queue_size = max_queue_size - self.scheduled_delay_ms = scheduled_delay_ms - self.exporter_timeout_ms = exporter_timeout_ms - self.max_export_batch_size = max_export_batch_size diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py deleted file mode 100644 index 50c02a4e..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ /dev/null @@ -1,443 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -import json -import logging -import os -from collections.abc import Callable, Sequence -from typing import Any, TypeVar -from urllib.parse import urlparse - -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.trace import SpanKind, StatusCode - -from ..constants import ( - APPLY_GUARDRAIL_OPERATION_NAME, - CHAT_OPERATION_NAME, - ENABLE_A365_OBSERVABILITY_EXPORTER, - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_AGENT_ID_KEY, - GEN_AI_OPERATION_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, - OUTPUT_MESSAGES_OPERATION_NAME, - TENANT_ID_KEY, -) -from ..inference_operation_type import InferenceOperationType - -logger = logging.getLogger(__name__) - -# Maximum allowed span size in bytes (250KB) -MAX_SPAN_SIZE_BYTES = 250 * 1024 - -# Operation names that identify a span as eligible for export to the Agent 365 -# observability ingest service. Only spans whose gen_ai.operation.name matches -# one of these values are included; all other spans are filtered out. -# -# Inference spans now emit the canonical lowercase operation name for their -# InferenceOperationType (e.g. "chat", "text_completion", "generate_content" - -# see InferenceOperationType.canonical_name), so all three are recognized here. -# The legacy PascalCase "Chat" value is kept for back-compat with any -# previously-emitted/non-Python spans that still use the raw enum value. -GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset( - { - INVOKE_AGENT_OPERATION_NAME, - EXECUTE_TOOL_OPERATION_NAME, - OUTPUT_MESSAGES_OPERATION_NAME, - APPLY_GUARDRAIL_OPERATION_NAME, - CHAT_OPERATION_NAME, - InferenceOperationType.CHAT.value, - InferenceOperationType.CHAT.canonical_name, - InferenceOperationType.TEXT_COMPLETION.canonical_name, - InferenceOperationType.GENERATE_CONTENT.canonical_name, - } -) - - -def hex_trace_id(value: int) -> str: - # 128-bit -> 32 hex chars - return f"{value:032x}" - - -def hex_span_id(value: int) -> str: - # 64-bit -> 16 hex chars - return f"{value:016x}" - - -def as_str(v: Any) -> str | None: - if v is None: - return None - s = str(v) - return s if s.strip() else None - - -def kind_name(kind: SpanKind) -> str: - # Return span kind name (enum name or numeric) - try: - return kind.name # Enum in otel 1.27+ - except Exception: - return str(kind) - - -def status_name(code: StatusCode) -> str: - try: - return code.name - except Exception: - return str(code) - - -def truncate_span(span_dict: dict[str, Any]) -> dict[str, Any]: - """ - Truncate span attributes if the serialized span exceeds MAX_SPAN_SIZE_BYTES. - - Args: - span_dict: The span dictionary to potentially truncate - - Returns: - The potentially truncated span dictionary - """ - try: - # Serialize the span to check its size - serialized = json.dumps(span_dict, separators=(",", ":")) - current_size = len(serialized.encode("utf-8")) - - if current_size <= MAX_SPAN_SIZE_BYTES: - return span_dict - - logger.warning( - f"Span size ({current_size} bytes) exceeds limit ({MAX_SPAN_SIZE_BYTES} bytes). " - "Truncating large payload attributes." - ) - - # Create a deep copy to modify (shallow copy would still reference original attributes) - truncated_span = span_dict.copy() - if "attributes" in truncated_span: - truncated_span["attributes"] = truncated_span["attributes"].copy() - attributes = truncated_span.get("attributes", {}) - - # Track what was truncated for logging - truncated_keys = [] - - # Sort attributes by size (largest first) and truncate until size is acceptable - if attributes: - # Calculate size of each attribute value when serialized - attr_sizes = [] - for key, value in attributes.items(): - try: - value_size = len(json.dumps(value, separators=(",", ":")).encode("utf-8")) - attr_sizes.append((key, value_size)) - except Exception: - # If we can't serialize the value, assume it's small - attr_sizes.append((key, 0)) - - # Sort by size (descending - largest first) - attr_sizes.sort(key=lambda x: x[1], reverse=True) - - # Truncate largest attributes first until size is acceptable - for key, _ in attr_sizes: - if key in attributes: - attributes[key] = "TRUNCATED" - truncated_keys.append(key) - - # Check size after truncation - serialized = json.dumps(truncated_span, separators=(",", ":")) - current_size = len(serialized.encode("utf-8")) - - if current_size <= MAX_SPAN_SIZE_BYTES: - break - - if truncated_keys: - logger.info(f"Truncated attributes: {', '.join(truncated_keys)}") - - return truncated_span - - except Exception as e: - logger.error(f"Error during span truncation: {e}") - return span_dict - - -def filter_and_partition_by_identity( - spans: Sequence[ReadableSpan], -) -> dict[tuple[str, str], list[ReadableSpan]]: - """ - Filter export-eligible spans and partition them by (tenantId, agentId). - - Only spans whose ``gen_ai.operation.name`` is in - ``GEN_AI_OPERATION_NAMES`` are included; non-genAI spans (e.g. HTTP, DB) - and spans with other operation names are filtered out. Spans without - both tenant and agent identity are also skipped. - """ - groups: dict[tuple[str, str], list[ReadableSpan]] = {} - non_gen_ai_count = 0 - missing_identity_count = 0 - for sp in spans: - attrs = sp.attributes or {} - operation_name = as_str(attrs.get(GEN_AI_OPERATION_NAME_KEY)) - if not operation_name or operation_name not in GEN_AI_OPERATION_NAMES: - non_gen_ai_count += 1 - continue - tenant = as_str(attrs.get(TENANT_ID_KEY)) - agent = as_str(attrs.get(GEN_AI_AGENT_ID_KEY)) - if not tenant or not agent: - missing_identity_count += 1 - continue - key = (tenant, agent) - groups.setdefault(key, []).append(sp) - - if non_gen_ai_count > 0: - logger.debug( - "[Agent365Exporter] %d spans without an eligible gen_ai.operation.name filtered out", - non_gen_ai_count, - ) - if missing_identity_count > 0: - logger.debug( - "[Agent365Exporter] %d spans skipped due to missing tenant or agent ID", - missing_identity_count, - ) - return groups - - -def get_validated_domain_override() -> str | None: - """ - Get and validate the domain override from environment variable. - - Returns: - The validated domain override, or None if not set or invalid. - """ - domain_override = os.getenv("A365_OBSERVABILITY_DOMAIN_OVERRIDE", "").strip() - if not domain_override: - return None - - # Validate that it's a valid URL - try: - parsed = urlparse(domain_override) - - # If scheme is present and looks like a protocol (contains //) - # Note: We check for "://" because urlparse treats "example.com:8080" as having - # scheme="example.com", but this is actually a domain with port, not a protocol. - if parsed.scheme and "://" in domain_override: - # Validate it's http or https - if parsed.scheme not in ("http", "https"): - logger.warning( - f"Invalid domain override '{domain_override}': " - f"scheme must be http or https, got '{parsed.scheme}'" - ) - return None - # Must have a netloc (hostname) when scheme is present - if not parsed.netloc: - logger.warning(f"Invalid domain override '{domain_override}': missing hostname") - return None - else: - # If no scheme with ://, it should be a domain with optional port (no path) - # Note: domain can contain : for port (e.g., example.com:8080) - # Reject malformed URLs like "http:8080" that look like protocols but aren't - if domain_override.startswith(("http:", "https:")) and "://" not in domain_override: - logger.warning( - f"Invalid domain override '{domain_override}': " - "malformed URL - protocol requires '://'" - ) - return None - if "/" in domain_override: - logger.warning( - f"Invalid domain override '{domain_override}': " - "domain without protocol should not contain path separators (/)" - ) - return None - except Exception as e: - logger.warning(f"Invalid domain override '{domain_override}': {e}") - return None - - # Warn when using insecure HTTP — telemetry data and bearer tokens may be exposed - if domain_override.lower().startswith("http://"): - logger.warning( - "Domain override uses insecure HTTP. Telemetry data (including " - "bearer tokens) will be transmitted in cleartext." - ) - - return domain_override - - -def build_export_url( - endpoint: str, agent_id: str, tenant_id: str, use_s2s_endpoint: bool = False -) -> str: - """Construct the full export URL from endpoint and agent ID. - - Args: - endpoint: Base endpoint URL or domain. - agent_id: The agent identifier to include in the URL path. - tenant_id: The tenant identifier to include in the URL path. - use_s2s_endpoint: Deprecated compatibility option. Ignored; export always uses - the S2S endpoint path format. - - Returns: - The fully constructed export URL with path and query parameters. - """ - endpoint_path = f"/observabilityService/tenants/{tenant_id}/otlp/agents/{agent_id}/traces" - - parsed = urlparse(endpoint) - if parsed.scheme and "://" in endpoint: - return f"{endpoint}{endpoint_path}?api-version=1" - return f"https://{endpoint}{endpoint_path}?api-version=1" - - -def parse_retry_after(headers: dict[str, str]) -> float | None: - """Parse the ``Retry-After`` header value. - - Only numeric (seconds) values are supported. HTTP-date values - (e.g. ``Wed, 21 Oct 2025 07:28:00 GMT``) are intentionally ignored - and treated as absent, falling back to exponential backoff. - - Args: - headers: Response headers mapping. - - Returns: - The number of seconds to wait, or ``None`` if the header is - absent, non-numeric, or otherwise invalid. - """ - retry_after = headers.get("Retry-After") - if retry_after is None: - return None - try: - return float(retry_after) - except (ValueError, TypeError): - # Intentionally ignore HTTP-date formatted Retry-After values; - # callers should fall back to exponential backoff. - return None - - -def is_agent365_exporter_enabled() -> bool: - """Check if Agent 365 exporter is enabled.""" - # Check environment variable - enable_exporter = os.getenv(ENABLE_A365_OBSERVABILITY_EXPORTER, "").lower() - return (enable_exporter) in ("true", "1", "yes", "on") - - -# --------------------------------------------------------------------------- -# Span size estimation and byte-level chunking -# --------------------------------------------------------------------------- - -# Default upper bound on HTTP request body size in bytes. Provides ~100 KB -# headroom under the A365 1 MB server limit for estimator error and JSON/ -# envelope overhead (e.g. resource attributes and scope wrappers). -DEFAULT_MAX_PAYLOAD_BYTES = 900_000 - -# Overhead constant for OTLP JSON span fixed fields (traceId, spanId, -# parentSpanId, kind, timestamps, status, scope wrapper, etc.). Intentionally -# generous to account for serializer variance. -_SPAN_BASE_OVERHEAD = 2000 - -# Overhead per attribute in OTLP JSON format. Covers key/value JSON wrapping. -_ATTR_OVERHEAD = 80 - -# Overhead per event in OTLP JSON. -_EVENT_OVERHEAD = 200 - - -def _utf8_len(s: str) -> int: - return len(s.encode("utf-8")) - - -def estimate_value_bytes(value: Any) -> int: - """Estimate the serialized byte size of a single attribute value in OTLP/HTTP JSON.""" - if isinstance(value, str): - return 40 + _utf8_len(value) - # bool is a subclass of int; check before sequence/list handling below - if isinstance(value, bool): - return 40 - if isinstance(value, (list, tuple)): - if len(value) == 0: - return 60 - first = value[0] - if isinstance(first, str): - total = 60 - for s in value: - total += 40 + _utf8_len(str(s)) - return total - return 60 + 50 * len(value) - return 40 # int/float/None/other - - -def estimate_span_bytes(span: dict[str, Any]) -> int: - """Heuristic estimator for the serialized size of an OTLP span in HTTP JSON. - - Uses generous constants tuned to over-estimate by ~25-50%, providing - headroom for JSON serializer variance (whitespace, enum representation, - integer-as-string). - """ - total = _SPAN_BASE_OVERHEAD - name = span.get("name") - if isinstance(name, str): - total += _utf8_len(name) - - attributes = span.get("attributes") - if attributes: - for key, value in attributes.items(): - total += _ATTR_OVERHEAD - total += _utf8_len(str(key)) - total += estimate_value_bytes(value) - - events = span.get("events") - if events: - for ev in events: - total += _EVENT_OVERHEAD - ev_name = ev.get("name") if isinstance(ev, dict) else None - if isinstance(ev_name, str): - total += _utf8_len(ev_name) - ev_attrs = ev.get("attributes") if isinstance(ev, dict) else None - if ev_attrs: - for key, value in ev_attrs.items(): - total += _ATTR_OVERHEAD - total += _utf8_len(str(key)) - total += estimate_value_bytes(value) - return total - - -T = TypeVar("T") - - -def chunk_by_size( - items: Sequence[T], - get_size: Callable[[T], int], - max_chunk_bytes: int, -) -> list[list[T]]: - """Split items into sub-batches whose cumulative estimated size stays under ``max_chunk_bytes``. - - Multi-item chunks are guaranteed to stay within the limit. A single item - whose estimated size exceeds ``max_chunk_bytes`` forms its own one-item - chunk (never silently dropped) even though that chunk exceeds the limit. - - Invariants: - - Input order is preserved across chunks. - - Empty input produces empty output. - - No item is ever dropped. - - No chunk is ever empty. - - Raises: - ValueError: If ``max_chunk_bytes`` is not positive, or if ``get_size`` - returns a negative value for any item. - """ - if max_chunk_bytes <= 0: - raise ValueError(f"max_chunk_bytes must be positive, got {max_chunk_bytes}") - - chunks: list[list[T]] = [] - current: list[T] = [] - current_bytes = 0 - - for item in items: - item_bytes = get_size(item) - if item_bytes < 0: - raise ValueError( - f"get_size returned a negative value ({item_bytes}); sizes must be non-negative" - ) - if current and current_bytes + item_bytes > max_chunk_bytes: - chunks.append(current) - current = [] - current_bytes = 0 - current.append(item) - current_bytes += item_bytes - - if current: - chunks.append(current) - - return chunks diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_decision_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_decision_type.py deleted file mode 100644 index e955afd3..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_decision_type.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from enum import Enum - - -class GuardrailDecisionType(Enum): - """The decision made by a security guardian during guardrail evaluation.""" - - ALLOW = "allow" - """Content or action is allowed to proceed.""" - - AUDIT = "audit" - """Content or action is logged for review but allowed to proceed.""" - - DENY = "deny" - """Content or action is denied/blocked.""" - - MODIFY = "modify" - """Content was modified (e.g., redacted, sanitized, rewritten).""" - - WARN = "warn" - """Content or action triggered a warning but is allowed to proceed.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_details.py deleted file mode 100644 index f4fe12bd..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_details.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass - -from .guardrail_decision_type import GuardrailDecisionType - - -@dataclass(frozen=True) -class GuardrailDetails: - """Details of a guardrail evaluation for security operations tracing. - - Args: - target_type: The type of content or action the guardrail is applied to (required). - See GuardrailTargetType for well-known values. - decision_type: The decision made by the guardian (required). - guardian_name: Human-readable name of the guardian. - guardian_id: Unique identifier of the guardian. - guardian_provider_name: Provider of the guardian service - (e.g., azure.ai.content_safety). - guardian_version: Version of the guardian. - target_id: Identifier of the target being guarded. - decision_reason: Human-readable explanation for the decision. - decision_code: Machine-readable decision code. - policy_id: Identifier of the policy that triggered the decision. - policy_name: Human-readable name of the policy. - policy_version: Version of the policy. - content_input_hash: Hash of the input content for forensic correlation. - content_modified: Whether content was modified by the guardrail. - external_event_id: External correlation identifier for SIEM systems. - """ - - target_type: str - decision_type: GuardrailDecisionType - guardian_name: str | None = None - guardian_id: str | None = None - guardian_provider_name: str | None = None - guardian_version: str | None = None - target_id: str | None = None - decision_reason: str | None = None - decision_code: str | None = None - policy_id: str | None = None - policy_name: str | None = None - policy_version: str | None = None - content_input_hash: str | None = None - content_modified: bool | None = None - external_event_id: str | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_finding.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_finding.py deleted file mode 100644 index 1210da1c..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_finding.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class GuardrailFinding: - """Represents a single security finding detected during guardian evaluation. - - Multiple findings may be emitted for a single guardrail span. - - Args: - risk_category: The category of security risk detected (required). - Common values: prompt_injection, sensitive_info_disclosure, jailbreak, - toxicity, pii. - risk_severity: The severity level of the detected risk (required). - See GuardrailRiskSeverity for well-known values. - policy_decision_type: The decision type for this specific policy finding. - policy_id: Identifier of the policy that triggered the finding. - policy_name: Human-readable name of the triggered policy. - policy_version: Version of the policy. - risk_score: Numeric risk/confidence score (0.0 to 1.0). - risk_metadata: Non-content metadata about the detected risk - (MUST NOT contain PII). - """ - - risk_category: str - risk_severity: str - policy_decision_type: str | None = None - policy_id: str | None = None - policy_name: str | None = None - policy_version: str | None = None - risk_score: float | None = None - risk_metadata: list[str] | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_risk_severity.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_risk_severity.py deleted file mode 100644 index 650823bf..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_risk_severity.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -class GuardrailRiskSeverity: - """Well-known severity levels for security risks detected by guardrails.""" - - NONE = "none" - """No risk detected.""" - - LOW = "low" - """Low severity risk.""" - - MEDIUM = "medium" - """Medium severity risk.""" - - HIGH = "high" - """High severity risk.""" - - CRITICAL = "critical" - """Critical severity risk requiring immediate action.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_target_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_target_type.py deleted file mode 100644 index c1b471c4..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/guardrail_target_type.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -class GuardrailTargetType: - """Well-known values for the type of content or action a guardrail is applied to. - - This is a free-form field per the OpenTelemetry semantic conventions. - These class attributes provide discoverability for common values, but custom - strings are also accepted when constructing GuardrailDetails. - """ - - LLM_INPUT = "llm_input" - """Input to a language model.""" - - LLM_OUTPUT = "llm_output" - """Output from a language model.""" - - TOOL_CALL = "tool_call" - """A tool call action.""" - - TOOL_DEFINITION = "tool_definition" - """A tool definition.""" - - MEMORY_STORE = "memory_store" - """A memory store operation.""" - - MEMORY_RETRIEVE = "memory_retrieve" - """A memory retrieval operation.""" - - KNOWLEDGE_QUERY = "knowledge_query" - """A knowledge query.""" - - KNOWLEDGE_RESULT = "knowledge_result" - """A knowledge retrieval result.""" - - MESSAGE = "message" - """A message.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py deleted file mode 100644 index 3d41cfc5..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_call_details.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass - -from .inference_operation_type import InferenceOperationType -from .models.service_endpoint import ServiceEndpoint - - -@dataclass -class InferenceCallDetails: - """Details of an inference call for generative AI operations.""" - - operationName: InferenceOperationType - model: str | None - providerName: str - inputTokens: int | None = None - outputTokens: int | None = None - finishReasons: list[str] | None = None - thoughtProcess: str | None = None - endpoint: ServiceEndpoint | None = None - # NOTE: Appended after all pre-existing fields (rather than inserted in the - # middle) to preserve positional-argument compatibility for existing callers. - responseModel: str | None = None - cacheReadInputTokens: int | None = None - cacheWriteInputTokens: int | None = None - - def __post_init__(self) -> None: - """Normalize and validate required model identity. - - Whitespace-only `model`/`responseModel` values are normalized to - `None` so that validation and downstream consumers (e.g. - `InferenceScope`) never treat a blank string as a present model. - """ - if self.model is not None: - self.model = self.model.strip() or None - if self.responseModel is not None: - self.responseModel = self.responseModel.strip() or None - - if not self.model and not self.responseModel: - raise ValueError("At least one of model or responseModel must be provided") diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py deleted file mode 100644 index a50ea35a..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_operation_type.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from enum import Enum - - -class InferenceOperationType(Enum): - """Supported inference operation types for generative AI.""" - - CHAT = "Chat" - TEXT_COMPLETION = "TextCompletion" - GENERATE_CONTENT = "GenerateContent" - - @property - def canonical_name(self) -> str: - """The canonical lowercase operation name per the OpenTelemetry GenAI - semantic conventions (e.g. ``chat``, ``text_completion``, - ``generate_content``). Used for both the span name prefix and the - ``gen_ai.operation.name`` attribute. - """ - return self.name.lower() diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py deleted file mode 100644 index 800637e0..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py +++ /dev/null @@ -1,222 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from opentelemetry.trace import SpanKind - -from .agent_details import AgentDetails -from .constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_THOUGHT_PROCESS_KEY, - GEN_AI_CALLER_CLIENT_IP_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_RESPONSE_FINISH_REASONS_KEY, - GEN_AI_RESPONSE_MODEL_KEY, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) -from .inference_call_details import InferenceCallDetails -from .message_utils import ( - normalize_input_messages, - normalize_output_messages, - serialize_messages, -) -from .models.messages import InputMessagesParam, OutputMessagesParam -from .models.user_details import UserDetails -from .opentelemetry_scope import OpenTelemetryScope -from .request import Request -from .span_details import SpanDetails -from .utils import safe_json_dumps, validate_and_normalize_ip - - -class InferenceScope(OpenTelemetryScope): - """Provides OpenTelemetry tracing scope for generative AI inference operations.""" - - @staticmethod - def start( - request: Request, - details: InferenceCallDetails, - agent_details: AgentDetails, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ) -> "InferenceScope": - """Creates and starts a new scope for inference tracing. - - Args: - request: Request details for the inference - details: The details of the inference call - agent_details: The details of the agent making the call - user_details: Optional human user details - span_details: Optional span configuration (parent context, timing) - - Returns: - A new InferenceScope instance - """ - return InferenceScope(request, details, agent_details, user_details, span_details) - - def __init__( - self, - request: Request, - details: InferenceCallDetails, - agent_details: AgentDetails, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ): - """Initialize the inference scope. - - Args: - request: Request details for the inference - details: The details of the inference call - agent_details: The details of the agent making the call - user_details: Optional human user details - span_details: Optional span configuration (parent context, timing) - """ - # spanKind for InferenceScope is always CLIENT - resolved_span_details = ( - SpanDetails( - span_kind=SpanKind.CLIENT, - parent_context=span_details.parent_context if span_details else None, - start_time=span_details.start_time if span_details else None, - end_time=span_details.end_time if span_details else None, - span_links=span_details.span_links if span_details else None, - ) - if span_details - else SpanDetails(span_kind=SpanKind.CLIENT) - ) - - model_name = details.model or details.responseModel - operation_name = details.operationName.canonical_name - super().__init__( - operation_name=operation_name, - activity_name=f"{operation_name} {model_name}", - agent_details=agent_details, - span_details=resolved_span_details, - ) - - if request.content is not None: - self.record_input_messages(request.content) - self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) - - self.set_tag_maybe(GEN_AI_OPERATION_NAME_KEY, operation_name) - self.set_tag_maybe(GEN_AI_REQUEST_MODEL_KEY, details.model) - self.set_tag_maybe(GEN_AI_RESPONSE_MODEL_KEY, details.responseModel) - self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, details.providerName) - self.set_tag_maybe( - GEN_AI_USAGE_INPUT_TOKENS_KEY, - details.inputTokens if details.inputTokens is not None else None, - ) - self.set_tag_maybe( - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - details.outputTokens if details.outputTokens is not None else None, - ) - self.set_tag_maybe( - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - details.cacheReadInputTokens, - ) - self.set_tag_maybe( - GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - details.cacheWriteInputTokens, - ) - self.set_tag_maybe( - GEN_AI_RESPONSE_FINISH_REASONS_KEY, - safe_json_dumps(details.finishReasons) if details.finishReasons else None, - ) - self.set_tag_maybe(GEN_AI_AGENT_THOUGHT_PROCESS_KEY, details.thoughtProcess) - - # Set endpoint information if provided - if details.endpoint: - self.set_tag_maybe(SERVER_ADDRESS_KEY, details.endpoint.hostname) - if details.endpoint.port: - self.set_tag_maybe(SERVER_PORT_KEY, details.endpoint.port) - - # Set request metadata if provided - if request.channel: - self.set_tag_maybe(CHANNEL_NAME_KEY, request.channel.name) - self.set_tag_maybe(CHANNEL_LINK_KEY, request.channel.link) - - # Set user details if provided - if user_details: - self.set_tag_maybe(USER_ID_KEY, user_details.user_id) - self.set_tag_maybe(USER_EMAIL_KEY, user_details.user_email) - self.set_tag_maybe(USER_NAME_KEY, user_details.user_name) - self.set_tag_maybe( - GEN_AI_CALLER_CLIENT_IP_KEY, - validate_and_normalize_ip(user_details.user_client_ip), - ) - - def record_input_messages(self, messages: InputMessagesParam) -> None: - """Records the input messages for telemetry tracking. - - Accepts plain strings (auto-wrapped as OTEL ChatMessage with role ``user``) - or a versioned ``InputMessages`` wrapper. - - Args: - messages: List of input message strings or an InputMessages wrapper - """ - wrapper = normalize_input_messages(messages) - self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, serialize_messages(wrapper)) - - def record_output_messages(self, messages: OutputMessagesParam) -> None: - """Records the output messages for telemetry tracking. - - Accepts plain strings (auto-wrapped as OTEL OutputMessage with role ``assistant``) - or a versioned ``OutputMessages`` wrapper. - - Args: - messages: List of output message strings or an OutputMessages wrapper - """ - wrapper = normalize_output_messages(messages) - self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, serialize_messages(wrapper)) - - def record_input_tokens(self, input_tokens: int) -> None: - """Records the number of input tokens for telemetry tracking. - - Args: - input_tokens: Number of input tokens - """ - self.set_tag_maybe(GEN_AI_USAGE_INPUT_TOKENS_KEY, input_tokens) - - def record_output_tokens(self, output_tokens: int) -> None: - """Records the number of output tokens for telemetry tracking. - - Args: - output_tokens: Number of output tokens - """ - self.set_tag_maybe(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, output_tokens) - - def record_cache_read_input_tokens(self, input_tokens: int) -> None: - """Record the number of input tokens read from a cache.""" - self.set_tag_maybe(GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, input_tokens) - - def record_cache_write_input_tokens(self, input_tokens: int) -> None: - """Record the number of input tokens written to a cache.""" - self.set_tag_maybe(GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, input_tokens) - - def record_finish_reasons(self, finish_reasons: list[str]) -> None: - """Records the finish reasons for telemetry tracking. - - Args: - finish_reasons: List of finish reasons - """ - if finish_reasons: - self.set_tag_maybe(GEN_AI_RESPONSE_FINISH_REASONS_KEY, safe_json_dumps(finish_reasons)) - - def record_thought_process(self, thought_process: str) -> None: - """Records the thought process. - - Args: - thought_process: The thought process to record - """ - self.set_tag_maybe(GEN_AI_AGENT_THOUGHT_PROCESS_KEY, thought_process) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py deleted file mode 100644 index 94655a1f..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_details.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Data class for invoke agent scope details. - -from dataclasses import dataclass - -from .models.service_endpoint import ServiceEndpoint - - -@dataclass -class InvokeAgentScopeDetails: - """Scope-level configuration for agent invocation tracing.""" - - endpoint: ServiceEndpoint | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py deleted file mode 100644 index 8a7d91b0..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py +++ /dev/null @@ -1,207 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Invoke agent scope for tracing agent invocation. - -import logging - -from opentelemetry.trace import SpanKind - -from .agent_details import AgentDetails -from .constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, - GEN_AI_CALLER_AGENT_EMAIL_KEY, - GEN_AI_CALLER_AGENT_ID_KEY, - GEN_AI_CALLER_AGENT_NAME_KEY, - GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, - GEN_AI_CALLER_AGENT_USER_ID_KEY, - GEN_AI_CALLER_AGENT_VERSION_KEY, - GEN_AI_CALLER_CLIENT_IP_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - INVOKE_AGENT_OPERATION_NAME, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, - SESSION_ID_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) -from .invoke_agent_details import InvokeAgentScopeDetails -from .message_utils import ( - normalize_input_messages, - normalize_output_messages, - serialize_messages, -) -from .models.caller_details import CallerDetails -from .models.messages import InputMessagesParam, OutputMessagesParam -from .opentelemetry_scope import OpenTelemetryScope -from .request import Request -from .span_details import SpanDetails -from .utils import validate_and_normalize_ip - -logger = logging.getLogger(__name__) - - -class InvokeAgentScope(OpenTelemetryScope): - """Provides OpenTelemetry tracing scope for AI agent invocation operations.""" - - @staticmethod - def start( - request: Request, - scope_details: InvokeAgentScopeDetails, - agent_details: AgentDetails, - caller_details: CallerDetails | None = None, - span_details: SpanDetails | None = None, - ) -> "InvokeAgentScope": - """Create and start a new scope for agent invocation tracing. - - Args: - request: Request details for the invocation - scope_details: Scope-level configuration (endpoint) - agent_details: The details of the agent being invoked - caller_details: Optional composite caller details (human user and/or - calling agent for A2A scenarios) - span_details: Optional span configuration (parent context, timing, kind) - - Returns: - A new InvokeAgentScope instance - """ - return InvokeAgentScope( - request, - scope_details, - agent_details, - caller_details, - span_details, - ) - - def __init__( - self, - request: Request, - scope_details: InvokeAgentScopeDetails, - agent_details: AgentDetails, - caller_details: CallerDetails | None = None, - span_details: SpanDetails | None = None, - ): - """Initialize the agent invocation scope. - - Args: - request: Request details for the invocation - scope_details: Scope-level configuration (endpoint) - agent_details: The details of the agent being invoked - caller_details: Optional composite caller details (human user and/or - calling agent for A2A scenarios) - span_details: Optional span configuration (parent context, timing, kind) - """ - activity_name = INVOKE_AGENT_OPERATION_NAME - if agent_details.agent_name: - activity_name = f"{INVOKE_AGENT_OPERATION_NAME} {agent_details.agent_name}" - - # spanKind defaults to CLIENT; allow override via span_details - resolved_span_details = ( - SpanDetails( - span_kind=span_details.span_kind - if span_details and span_details.span_kind - else SpanKind.CLIENT, - parent_context=span_details.parent_context if span_details else None, - start_time=span_details.start_time if span_details else None, - end_time=span_details.end_time if span_details else None, - span_links=span_details.span_links if span_details else None, - ) - if span_details - else SpanDetails(span_kind=SpanKind.CLIENT) - ) - - super().__init__( - operation_name=INVOKE_AGENT_OPERATION_NAME, - activity_name=activity_name, - agent_details=agent_details, - span_details=resolved_span_details, - ) - - self.set_tag_maybe(SESSION_ID_KEY, request.session_id) - self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) - - endpoint = scope_details.endpoint - if endpoint: - self.set_tag_maybe(SERVER_ADDRESS_KEY, endpoint.hostname) - if endpoint.port and endpoint.port != 443: - self.set_tag_maybe(SERVER_PORT_KEY, endpoint.port) - - # Set request metadata - if request.channel: - self.set_tag_maybe(CHANNEL_NAME_KEY, request.channel.name) - self.set_tag_maybe(CHANNEL_LINK_KEY, request.channel.link) - if request.content is not None: - self.record_input_messages(request.content) - - # Set caller details tags - if caller_details: - user_details = caller_details.user_details - if user_details: - self.set_tag_maybe(USER_ID_KEY, user_details.user_id) - self.set_tag_maybe(USER_EMAIL_KEY, user_details.user_email) - self.set_tag_maybe(USER_NAME_KEY, user_details.user_name) - self.set_tag_maybe( - GEN_AI_CALLER_CLIENT_IP_KEY, - validate_and_normalize_ip(user_details.user_client_ip), - ) - - # Set caller agent details tags - caller_agent_details = caller_details.caller_agent_details - if caller_agent_details: - self.set_tag_maybe(GEN_AI_CALLER_AGENT_NAME_KEY, caller_agent_details.agent_name) - self.set_tag_maybe(GEN_AI_CALLER_AGENT_ID_KEY, caller_agent_details.agent_id) - self.set_tag_maybe( - GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, - caller_agent_details.agent_blueprint_id, - ) - self.set_tag_maybe( - GEN_AI_CALLER_AGENT_USER_ID_KEY, caller_agent_details.agentic_user_id - ) - self.set_tag_maybe( - GEN_AI_CALLER_AGENT_EMAIL_KEY, caller_agent_details.agentic_user_email - ) - self.set_tag_maybe( - GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, - caller_agent_details.agent_platform_id, - ) - self.set_tag_maybe( - GEN_AI_CALLER_AGENT_VERSION_KEY, - caller_agent_details.agent_version, - ) - - def record_response(self, response: str) -> None: - """Record response information for telemetry tracking. - - Args: - response: The response string to record - """ - self.record_output_messages([response]) - - def record_input_messages(self, messages: InputMessagesParam) -> None: - """Record the input messages for telemetry tracking. - - Accepts plain strings (auto-wrapped as OTEL ChatMessage with role ``user``) - or a versioned ``InputMessages`` wrapper. - - Args: - messages: List of input message strings or an InputMessages wrapper - """ - wrapper = normalize_input_messages(messages) - self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, serialize_messages(wrapper)) - - def record_output_messages(self, messages: OutputMessagesParam) -> None: - """Record the output messages for telemetry tracking. - - Accepts plain strings (auto-wrapped as OTEL OutputMessage with role ``assistant``) - or a versioned ``OutputMessages`` wrapper. - - Args: - messages: List of output message strings or an OutputMessages wrapper - """ - wrapper = normalize_output_messages(messages) - self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, serialize_messages(wrapper)) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/message_utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/message_utils.py deleted file mode 100644 index 1a49d93a..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/message_utils.py +++ /dev/null @@ -1,146 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Conversion and serialization helpers for OTEL gen-ai message format. - -Provides normalization from plain ``list[str]`` (backward compat) to the -structured array format, and a non-throwing ``serialize_messages`` function. -""" - -from __future__ import annotations - -import json -import logging -from dataclasses import asdict -from enum import Enum -from typing import Union - -from .models.messages import ( - ChatMessage, - InputMessages, - InputMessagesParam, - MessageRole, - OutputMessage, - OutputMessages, - OutputMessagesParam, - TextPart, -) - -logger = logging.getLogger(__name__) - - -def is_string_list( - param: Union[InputMessagesParam, OutputMessagesParam], -) -> bool: - """Return ``True`` when *param* is a plain ``list[str]``.""" - return isinstance(param, list) and all(isinstance(item, str) for item in param) - - -def is_wrapped_messages( - param: Union[InputMessagesParam, OutputMessagesParam], -) -> bool: - """Return ``True`` when *param* is a structured message container.""" - return isinstance(param, (InputMessages, OutputMessages)) - - -# --------------------------------------------------------------------------- -# Plain-string → structured conversion -# --------------------------------------------------------------------------- - - -def to_input_messages(messages: list[str]) -> list[ChatMessage]: - """Convert plain input strings into OTEL ``ChatMessage`` objects.""" - return [ - ChatMessage(role=MessageRole.USER, parts=[TextPart(content=content)]) - for content in messages - ] - - -def to_output_messages(messages: list[str]) -> list[OutputMessage]: - """Convert plain output strings into OTEL ``OutputMessage`` objects.""" - return [ - OutputMessage(role=MessageRole.ASSISTANT, parts=[TextPart(content=content)]) - for content in messages - ] - - -# --------------------------------------------------------------------------- -# Normalization (union → versioned wrapper) -# --------------------------------------------------------------------------- - - -def normalize_input_messages(param: InputMessagesParam) -> InputMessages: - """Normalize an ``InputMessagesParam`` to an ``InputMessages`` instance. - - - ``str`` → wrapped in a single-element list, then converted. - - ``list[str]`` → converted to ``ChatMessage`` list and wrapped. - - ``InputMessages`` → returned as-is. - """ - if isinstance(param, str): - return InputMessages(messages=to_input_messages([param])) - if is_string_list(param): - return InputMessages(messages=to_input_messages(param)) # type: ignore[arg-type] - return param # type: ignore[return-value] - - -def normalize_output_messages(param: OutputMessagesParam) -> OutputMessages: - """Normalize an ``OutputMessagesParam`` to an ``OutputMessages`` instance. - - - ``str`` → wrapped in a single-element list, then converted. - - ``list[str]`` → converted to ``OutputMessage`` list and wrapped. - - ``OutputMessages`` → returned as-is. - """ - if isinstance(param, str): - return OutputMessages(messages=to_output_messages([param])) - if is_string_list(param): - return OutputMessages(messages=to_output_messages(param)) # type: ignore[arg-type] - return param # type: ignore[return-value] - - -# --------------------------------------------------------------------------- -# Serialization -# --------------------------------------------------------------------------- - - -def _message_dict_factory(items: list[tuple[str, object]]) -> dict[str, object]: - """Custom dict factory for ``dataclasses.asdict``. - - Drops ``None`` values and converts enum members to their string value. - """ - return {k: (v.value if isinstance(v, Enum) else v) for k, v in items if v is not None} - - -def serialize_messages( - wrapper: Union[InputMessages, OutputMessages], -) -> str: - """Serialize a message container to a JSON array. - - The output is a plain JSON array of message objects per OTel gen-ai - semantic conventions: ``[{"role":"user","parts":[...]}]``. - - The try/except ensures telemetry recording is non-throwing even when - message parts contain non-JSON-serializable values. - """ - try: - messages_dicts = [ - asdict(msg, dict_factory=_message_dict_factory) for msg in wrapper.messages - ] - return json.dumps(messages_dicts, default=str, ensure_ascii=False) - except Exception: - logger.warning("Failed to serialize messages; using fallback.", exc_info=True) - messages = getattr(wrapper, "messages", []) - count = len(messages) if isinstance(messages, list) else 0 - noun = "message" if count == 1 else "messages" - fallback_message = { - "role": MessageRole.SYSTEM.value, - "parts": [ - { - "type": "text", - "content": f"[serialization failed: {count} {noun}]", - } - ], - } - if isinstance(wrapper, OutputMessages): - fallback_message["finish_reason"] = "error" - fallback = [fallback_message] - return json.dumps(fallback, ensure_ascii=False) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py deleted file mode 100644 index 7ef448ca..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Middleware components for Microsoft Agent 365 SDK. - -from .baggage_builder import BaggageBuilder - -__all__ = ["BaggageBuilder"] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py deleted file mode 100644 index 6845f6e2..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py +++ /dev/null @@ -1,316 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Per request baggage builder for OpenTelemetry context propagation. - -import logging -from typing import Any - -from opentelemetry import baggage, context - -from ..constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_BLUEPRINT_ID_KEY, - GEN_AI_AGENT_DESCRIPTION_KEY, - GEN_AI_AGENT_EMAIL_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_AGENT_TYPE_KEY, - GEN_AI_AGENT_VERSION_KEY, - GEN_AI_CALLER_CLIENT_IP_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_CONVERSATION_ITEM_LINK_KEY, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, - SERVICE_NAME_KEY, - SESSION_DESCRIPTION_KEY, - SESSION_ID_KEY, - TENANT_ID_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) -from ..models.agent_type import AgentType -from ..utils import validate_and_normalize_ip - -logger = logging.getLogger(__name__) - - -class BaggageBuilder: - """Per request baggage builder. - - This class provides a fluent API for setting baggage values that will be - propagated in the OpenTelemetry context. - - Example: - .. code-block:: python - - builder = (BaggageBuilder() - .tenant_id("tenant-123") - .agent_id("agent-456")) - - with builder.build(): - # Baggage is set in this context - pass - # Baggage is restored after exiting the context - """ - - def __init__(self): - """Initialize the baggage builder.""" - self._pairs: dict[str, str] = {} - - def operation_source(self, value: str | None) -> "BaggageBuilder": - """Set the operation source baggage value. - - This captures the name of the service using the SDK. - - Args: - value: The service name (e.g., "my-agent-service", "weather-bot") - - Returns: - Self for method chaining - """ - self._set(SERVICE_NAME_KEY, value) - return self - - def tenant_id(self, value: str | None) -> "BaggageBuilder": - """Set the tenant ID baggage value. - - Args: - value: The tenant ID - - Returns: - Self for method chaining - """ - self._set(TENANT_ID_KEY, value) - return self - - def agent_id(self, value: str | None) -> "BaggageBuilder": - """Set the agent ID baggage value. - - Args: - value: The agent ID - - Returns: - Self for method chaining - """ - self._set(GEN_AI_AGENT_ID_KEY, value) - return self - - def agent_type(self, value: AgentType | str | None) -> "BaggageBuilder": - """Set the agent type baggage value. - - Accepts either an `AgentType` enum member or a plain string; enum - members are normalized to their string `.value` since OTel baggage - values must be primitive strings. - """ - self._set(GEN_AI_AGENT_TYPE_KEY, getattr(value, "value", value)) - return self - - def agentic_user_id(self, value: str | None) -> "BaggageBuilder": - """Set the agentic user ID baggage value. - - Args: - value: The agentic user ID - - Returns: - Self for method chaining - """ - self._set(GEN_AI_AGENT_AUID_KEY, value) - return self - - def agentic_user_email(self, value: str | None) -> "BaggageBuilder": - """Set the agentic user email baggage value. - - Args: - value: The agentic user email - - Returns: - Self for method chaining - """ - self._set(GEN_AI_AGENT_EMAIL_KEY, value) - return self - - def agent_blueprint_id(self, value: str | None) -> "BaggageBuilder": - """Set the agent blueprint ID baggage value. - - Args: - value: The agent blueprint ID - - Returns: - Self for method chaining - """ - self._set(GEN_AI_AGENT_BLUEPRINT_ID_KEY, value) - return self - - def user_id(self, value: str | None) -> "BaggageBuilder": - """Set the user ID baggage value. - - Args: - value: The user ID - - Returns: - Self for method chaining - """ - self._set(USER_ID_KEY, value) - return self - - def agent_name(self, value: str | None) -> "BaggageBuilder": - """Set the agent name baggage value.""" - self._set(GEN_AI_AGENT_NAME_KEY, value) - return self - - def agent_description(self, value: str | None) -> "BaggageBuilder": - """Set the agent description baggage value.""" - self._set(GEN_AI_AGENT_DESCRIPTION_KEY, value) - return self - - def agent_version(self, value: str | None) -> "BaggageBuilder": - """Set the agent version baggage value.""" - self._set(GEN_AI_AGENT_VERSION_KEY, value) - return self - - def user_name(self, value: str | None) -> "BaggageBuilder": - """Set the user name baggage value.""" - self._set(USER_NAME_KEY, value) - return self - - def user_email(self, value: str | None) -> "BaggageBuilder": - """Set the user email baggage value.""" - self._set(USER_EMAIL_KEY, value) - return self - - def user_client_ip(self, value: str | None) -> "BaggageBuilder": - """Set the user client IP baggage value.""" - self._set(GEN_AI_CALLER_CLIENT_IP_KEY, validate_and_normalize_ip(value)) - return self - - def invoke_agent_server(self, address: str | None, port: int | None = None) -> "BaggageBuilder": - """Set the invoke agent server address and port baggage values. - - Args: - address: The server address (hostname) of the target agent service. - port: Optional server port. Only recorded when different from 443. - - Returns: - Self for method chaining - """ - self._set(SERVER_ADDRESS_KEY, address) - if port is not None and port != 443: - self._set(SERVER_PORT_KEY, str(port)) - return self - - def conversation_id(self, value: str | None) -> "BaggageBuilder": - """Set the conversation ID baggage value.""" - self._set(GEN_AI_CONVERSATION_ID_KEY, value) - return self - - def conversation_item_link(self, value: str | None) -> "BaggageBuilder": - """Set the conversation item link baggage value.""" - self._set(GEN_AI_CONVERSATION_ITEM_LINK_KEY, value) - return self - - def session_id(self, value: str | None) -> "BaggageBuilder": - """Set the session ID baggage value.""" - self._set(SESSION_ID_KEY, value) - return self - - def session_description(self, value: str | None) -> "BaggageBuilder": - """Set the session description baggage value.""" - self._set(SESSION_DESCRIPTION_KEY, value) - return self - - def channel_name(self, value: str | None) -> "BaggageBuilder": - """Sets the channel name baggage value (e.g., 'Teams', 'msteams').""" - self._set(CHANNEL_NAME_KEY, value) - return self - - def channel_links(self, value: str | None) -> "BaggageBuilder": - """Sets the channel link baggage value.""" - self._set(CHANNEL_LINK_KEY, value) - return self - - def set_pairs(self, pairs: Any) -> "BaggageBuilder": - """ - Accept dict or iterable of (k,v). - """ - if not pairs: - return self - if isinstance(pairs, dict): - iterator = pairs.items() - else: - iterator = pairs - for k, v in iterator: - if v is None: - continue - self._set(str(k), str(v)) - return self - - def build(self) -> "BaggageScope": - """Apply the collected baggage to the current context. - - Returns: - A context manager that restores the previous baggage on exit - """ - return BaggageScope(self._pairs) - - def _set(self, key: str, value: str | None) -> None: - """Add a baggage key/value if the value is not None or whitespace. - - Args: - key: The baggage key - value: The baggage value - """ - if value is not None and value.strip(): - self._pairs[key] = value - - -class BaggageScope: - """Context manager for baggage scope. - - This class manages the lifecycle of baggage values, setting them on enter - and restoring the previous context on exit. - """ - - def __init__(self, pairs: dict[str, str]): - """Initialize the baggage scope. - - Args: - pairs: Dictionary of baggage key-value pairs to set - """ - self._pairs = pairs - self._previous_context: Any = None - self._token: Any = None - - def __enter__(self) -> "BaggageScope": - """Enter the context and set baggage values. - - Returns: - Self - """ - # Get the current context - self._previous_context = context.get_current() - - # Set all baggage values in the new context - new_context = self._previous_context - for key, value in self._pairs.items(): - if value and value.strip(): - new_context = baggage.set_baggage(key, value, context=new_context) - - # Attach the new context - self._token = context.attach(new_context) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Exit the context and restore previous baggage. - - Args: - exc_type: Exception type if an exception occurred - exc_val: Exception value if an exception occurred - exc_tb: Exception traceback if an exception occurred - """ - # Detach and restore previous context - if self._token is not None: - context.detach(self._token) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/agent_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/agent_type.py deleted file mode 100644 index 813df96c..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/agent_type.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from enum import Enum - - -class AgentType(Enum): - """ - Supported agent types for generative AI. - """ - - ENTRA_EMBODIED = "EntraEmbodied" - """Entra embodied agent.""" - - ENTRA_NON_EMBODIED = "EntraNonEmbodied" - """Entra non-embodied agent.""" - - MICROSOFT_COPILOT = "MicrosoftCopilot" - """Microsoft Copilot agent.""" - - DECLARATIVE_AGENT = "DeclarativeAgent" - """Declarative agent.""" - - FOUNDRY = "Foundry" - """Foundry agent.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/caller_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/caller_details.py deleted file mode 100644 index 2fb79e7c..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/caller_details.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from ..agent_details import AgentDetails - -from .user_details import UserDetails - - -@dataclass -class CallerDetails: - """Composite caller details for agent-to-agent (A2A) scenarios. - - Groups the human caller identity and the calling agent identity together. - """ - - user_details: Optional[UserDetails] = None - """Details about the human user in the call chain.""" - - caller_agent_details: Optional["AgentDetails"] = None - """Details about the calling agent in A2A scenarios.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/messages.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/messages.py deleted file mode 100644 index 9379b911..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/messages.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""OTEL gen-ai semantic convention message types. - -Defines the structured message format for input/output message tracing, -following the OpenTelemetry gen-ai semantic conventions: - https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json - https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Union - -# --------------------------------------------------------------------------- -# Enums -# --------------------------------------------------------------------------- - - -class MessageRole(Enum): - """Role of a message participant per OTEL gen-ai semantic conventions.""" - - SYSTEM = "system" - USER = "user" - ASSISTANT = "assistant" - TOOL = "tool" - - -class FinishReason(Enum): - """Reason a model stopped generating per OTEL gen-ai semantic conventions.""" - - STOP = "stop" - LENGTH = "length" - CONTENT_FILTER = "content_filter" - TOOL_CALL = "tool_call" - ERROR = "error" - - -class Modality(Enum): - """Media modality for blob, file, and URI parts.""" - - IMAGE = "image" - VIDEO = "video" - AUDIO = "audio" - - -# --------------------------------------------------------------------------- -# Message part types (discriminated on ``type``) -# --------------------------------------------------------------------------- - - -@dataclass -class TextPart: - """Plain text content.""" - - content: str - type: str = field(default="text", init=False) - - -@dataclass -class ToolCallRequestPart: - """A tool call requested by the model.""" - - name: str - id: str | None = None - arguments: dict[str, object] | list[object] | str | None = None - type: str = field(default="tool_call", init=False) - - -@dataclass -class ToolCallResponsePart: - """Result of a tool call.""" - - id: str | None = None - response: object | None = None - type: str = field(default="tool_call_response", init=False) - - -@dataclass -class ReasoningPart: - """Model reasoning / chain-of-thought content.""" - - content: str - type: str = field(default="reasoning", init=False) - - -@dataclass -class BlobPart: - """Inline binary data (base64-encoded).""" - - modality: Modality | str - content: str - mime_type: str | None = None - type: str = field(default="blob", init=False) - - -@dataclass -class FilePart: - """Reference to a pre-uploaded file.""" - - modality: Modality | str - file_id: str - mime_type: str | None = None - type: str = field(default="file", init=False) - - -@dataclass -class UriPart: - """External URI reference.""" - - modality: Modality | str - uri: str - mime_type: str | None = None - type: str = field(default="uri", init=False) - - -@dataclass -class ServerToolCallPart: - """Server-side tool invocation.""" - - name: str - server_tool_call: dict[str, object] - id: str | None = None - type: str = field(default="server_tool_call", init=False) - - -@dataclass -class ServerToolCallResponsePart: - """Server-side tool response.""" - - server_tool_call_response: dict[str, object] - id: str | None = None - type: str = field(default="server_tool_call_response", init=False) - - -@dataclass -class GenericPart: - """Extensible part for custom / future types.""" - - type: str - data: dict[str, object] = field(default_factory=dict) - - -MessagePart = Union[ - TextPart, - ToolCallRequestPart, - ToolCallResponsePart, - ReasoningPart, - BlobPart, - FilePart, - UriPart, - ServerToolCallPart, - ServerToolCallResponsePart, - GenericPart, -] -"""Union of all message part types per OTEL gen-ai semantic conventions.""" - - -# --------------------------------------------------------------------------- -# Message types -# --------------------------------------------------------------------------- - - -@dataclass -class ChatMessage: - """An input message sent to a model (OTEL gen-ai semantic conventions).""" - - role: MessageRole - parts: list[MessagePart] = field(default_factory=list) - name: str | None = None - - -@dataclass -class OutputMessage(ChatMessage): - """An output message produced by a model (OTEL gen-ai semantic conventions). - - ``finish_reason`` defaults to ``"stop"`` per OTel spec (required field). - """ - - finish_reason: str = "stop" - - -# --------------------------------------------------------------------------- -# Message containers -# --------------------------------------------------------------------------- - - -@dataclass -class InputMessages: - """Represents the list of input messages sent to the model. - - Serializes as a plain JSON array per OTel gen-ai semantic conventions. - """ - - messages: list[ChatMessage] = field(default_factory=list) - - -@dataclass -class OutputMessages: - """Represents the list of output messages generated by the model. - - Serializes as a plain JSON array per OTel gen-ai semantic conventions. - """ - - messages: list[OutputMessage] = field(default_factory=list) - - -# --------------------------------------------------------------------------- -# Parameter type aliases (backward-compatible union types) -# --------------------------------------------------------------------------- - -InputMessagesParam = Union[str, list[str], InputMessages] -"""Accepted input for ``record_input_messages``. - -Supports a single string, a list of strings (backward compat), or the versioned wrapper. -""" - -OutputMessagesParam = Union[str, list[str], OutputMessages] -"""Accepted input for ``record_output_messages``. - -Supports a single string, a list of strings (backward compat), or the versioned wrapper. -""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/operation_source.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/operation_source.py deleted file mode 100644 index 2395879b..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/operation_source.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Operation source enumeration for Agent365 SDK.""" - -from enum import Enum - - -class OperationSource(Enum): - """ - Enumeration representing the source of an operation. - """ - - SDK = "SDK" - """Operation executed by SDK.""" - - GATEWAY = "Gateway" - """Operation executed by Gateway.""" - - MCP_SERVER = "MCPServer" - """Operation executed by MCP Server.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py deleted file mode 100644 index 137f49bd..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/response.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Union - -from .messages import OutputMessagesParam - -ResponseMessagesParam = Union[OutputMessagesParam, dict[str, object]] -"""Accepted type for Response.messages. - -Supports plain strings, ``OutputMessages``, or a structured tool result dict. -A ``dict[str, object]`` is treated as a tool call result per OTEL spec and -serialized directly via ``json.dumps``. -""" - - -@dataclass -class Response: - """Response details from agent execution. - - Accepts plain strings (backward compat), structured ``OutputMessages``, - or a ``dict`` for tool call results (per OTEL spec). - """ - - messages: ResponseMessagesParam diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/service_endpoint.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/service_endpoint.py deleted file mode 100644 index 5382ab6c..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/service_endpoint.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass - - -@dataclass -class ServiceEndpoint: - """Represents a service endpoint with hostname and optional port.""" - - hostname: str - """The hostname of the service endpoint.""" - - port: int | None = None - """The port of the service endpoint.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/user_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/user_details.py deleted file mode 100644 index 93f71a0f..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/models/user_details.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class UserDetails: - """Details about the human user that invoked an agent.""" - - user_id: Optional[str] = None - """The unique identifier for the user.""" - - user_email: Optional[str] = None - """The email address of the user.""" - - user_name: Optional[str] = None - """The human-readable name of the user.""" - - user_client_ip: Optional[str] = None - """The client IP address of the user.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py deleted file mode 100644 index a7d40cc7..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ /dev/null @@ -1,355 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Base class for OpenTelemetry tracing scopes. - -import logging -import os -from datetime import datetime -from threading import Lock -from typing import TYPE_CHECKING, Any - -from opentelemetry import context, trace -from opentelemetry.context import Context -from opentelemetry.propagate import inject -from opentelemetry.trace import ( - Span, - SpanKind, - Status, - StatusCode, - Tracer, - set_span_in_context, -) - -from .constants import ( - ENABLE_A365_OBSERVABILITY, - ENABLE_OBSERVABILITY, - ERROR_TYPE_CANCELLED, - ERROR_TYPE_KEY, - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_BLUEPRINT_ID_KEY, - GEN_AI_AGENT_DESCRIPTION_KEY, - GEN_AI_AGENT_EMAIL_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_AGENT_PLATFORM_ID_KEY, - GEN_AI_AGENT_TYPE_KEY, - GEN_AI_AGENT_VERSION_KEY, - GEN_AI_ICON_URI_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - SOURCE_NAME, - TELEMETRY_SDK_LANGUAGE_KEY, - TELEMETRY_SDK_LANGUAGE_VALUE, - TELEMETRY_SDK_NAME_KEY, - TELEMETRY_SDK_NAME_VALUE, - TELEMETRY_SDK_VERSION_KEY, - TENANT_ID_KEY, -) -from .utils import get_sdk_version - -if TYPE_CHECKING: - from .agent_details import AgentDetails - from .span_details import SpanDetails - -# Create logger for this module - inherits from 'microsoft_agents_a365.observability.core' -logger = logging.getLogger(__name__) - - -class OpenTelemetryScope: - """Base class for OpenTelemetry tracing scopes in the SDK.""" - - _tracer: Tracer | None = None - _tracer_lock = Lock() - - @classmethod - def _get_tracer(cls) -> Tracer: - """Get the tracer instance, creating it if necessary.""" - if cls._tracer is None: - with cls._tracer_lock: - if cls._tracer is None: - cls._tracer = trace.get_tracer(SOURCE_NAME) - return cls._tracer - - @classmethod - def _is_telemetry_enabled(cls) -> bool: - """Check if telemetry is enabled.""" - # Check environment variable - env_value = os.getenv(ENABLE_OBSERVABILITY, "").lower() - enable_observability = os.getenv(ENABLE_A365_OBSERVABILITY, "").lower() - return (env_value or enable_observability) in ("true", "1", "yes", "on") - - @staticmethod - def _datetime_to_ns(dt: datetime | None) -> int | None: - """Convert a datetime to nanoseconds since epoch. - - Args: - dt: Python datetime object, or None - - Returns: - Nanoseconds since epoch, or None if input is None - """ - if dt is None: - return None - return int(dt.timestamp() * 1_000_000_000) - - def __init__( - self, - operation_name: str, - activity_name: str, - agent_details: "AgentDetails | None" = None, - span_details: "SpanDetails | None" = None, - ): - """Initialize the OpenTelemetry scope. - - Args: - operation_name: The name of the operation being traced - activity_name: The name of the activity for display purposes - agent_details: Optional agent details - span_details: Optional span configuration including parent context, - start/end times, span kind, and span links. Subclasses may override - ``span_details.span_kind`` before calling this constructor; - defaults to ``SpanKind.CLIENT``. - """ - parent_context = span_details.parent_context if span_details else None - start_time = span_details.start_time if span_details else None - end_time = span_details.end_time if span_details else None - span_links = span_details.span_links if span_details else None - kind = ( - span_details.span_kind if span_details and span_details.span_kind else SpanKind.CLIENT - ) - if not isinstance(kind, SpanKind): - logger.warning( - "span_details.span_kind has invalid type %s (value: %r); " - "falling back to SpanKind.CLIENT", - type(kind).__name__, - kind, - ) - kind = SpanKind.CLIENT - - self._span: Span | None = None - self._custom_start_time: datetime | None = start_time - self._custom_end_time: datetime | None = end_time - self._has_ended = False - self._error_type: str | None = None - self._exception: Exception | None = None - self._context_token = None - - if self._is_telemetry_enabled(): - tracer = self._get_tracer() - - activity_kind = kind - - # Get context for parent relationship - # If parent_context is provided, use it directly - # Otherwise, use the current context - span_context = parent_context if parent_context else context.get_current() - - # Convert custom start time to OTel-compatible format (nanoseconds since epoch) - otel_start_time = self._datetime_to_ns(start_time) - - self._span = tracer.start_span( - activity_name, - kind=activity_kind, - context=span_context, - start_time=otel_start_time, - links=span_links, - ) - - # Log span creation - if self._span: - sc = self._span.get_span_context() - span_id = f"{sc.span_id:016x}" if sc else "unknown" - logger.info(f"Span started: '{activity_name}' ({span_id})") - else: - logger.error(f"Failed to create span: '{activity_name}' - tracer returned None") - - # Set common tags - if self._span: - self._span.set_attribute(GEN_AI_OPERATION_NAME_KEY, operation_name) - - # Set telemetry SDK attributes - self._span.set_attribute(TELEMETRY_SDK_NAME_KEY, TELEMETRY_SDK_NAME_VALUE) - self._span.set_attribute(TELEMETRY_SDK_LANGUAGE_KEY, TELEMETRY_SDK_LANGUAGE_VALUE) - self._span.set_attribute(TELEMETRY_SDK_VERSION_KEY, get_sdk_version()) - - # Set agent details if provided - if agent_details: - self.set_tag_maybe(GEN_AI_AGENT_ID_KEY, agent_details.agent_id) - self.set_tag_maybe(GEN_AI_AGENT_NAME_KEY, agent_details.agent_name) - self.set_tag_maybe( - GEN_AI_AGENT_DESCRIPTION_KEY, agent_details.agent_description - ) - self.set_tag_maybe(GEN_AI_AGENT_VERSION_KEY, agent_details.agent_version) - self.set_tag_maybe(GEN_AI_AGENT_AUID_KEY, agent_details.agentic_user_id) - self.set_tag_maybe(GEN_AI_AGENT_EMAIL_KEY, agent_details.agentic_user_email) - self.set_tag_maybe( - GEN_AI_AGENT_BLUEPRINT_ID_KEY, agent_details.agent_blueprint_id - ) - self.set_tag_maybe( - GEN_AI_AGENT_PLATFORM_ID_KEY, agent_details.agent_platform_id - ) - # Accept either an `AgentType` enum member or a plain string; - # emit the string `.value` so OTel doesn't silently drop - # non-primitive attribute values. - self.set_tag_maybe( - GEN_AI_AGENT_TYPE_KEY, - getattr(agent_details.agent_type, "value", agent_details.agent_type), - ) - self.set_tag_maybe(TENANT_ID_KEY, agent_details.tenant_id) - self.set_tag_maybe(GEN_AI_ICON_URI_KEY, agent_details.icon_uri) - # Set provider name dynamically from agent details - self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, agent_details.provider_name) - - def record_error(self, exception: Exception) -> None: - """Record an error in the span. - - Args: - exception: The exception that occurred - """ - if self._span and self._is_telemetry_enabled(): - self._error_type = type(exception).__name__ - self._exception = exception - self._span.set_attribute(ERROR_TYPE_KEY, self._error_type) - self._span.record_exception(exception) - self._span.set_status(Status(StatusCode.ERROR, str(exception))) - - def record_response(self, response: str) -> None: - """Record an response in the span. - - Args: - response: The response content to record - """ - if self._span and self._is_telemetry_enabled(): - self._span.set_attribute(GEN_AI_OUTPUT_MESSAGES_KEY, response) - - def record_cancellation(self) -> None: - """Record task cancellation.""" - if self._span and self._is_telemetry_enabled(): - self._error_type = ERROR_TYPE_CANCELLED - self._span.set_attribute(ERROR_TYPE_KEY, self._error_type) - self._span.set_status(Status(StatusCode.ERROR, "Task was cancelled")) - - def set_tag_maybe(self, name: str, value: Any) -> None: - """Set a tag on the span if the value is not None. - - Args: - name: The name of the tag - value: The value to set (will be skipped if None) - """ - if value is not None and self._span and self._is_telemetry_enabled(): - self._span.set_attribute(name, value) - - def record_attributes(self, attributes: dict[str, Any] | list[tuple[str, Any]]) -> None: - """Record multiple attribute key/value pairs for telemetry tracking. - - This method allows setting multiple custom attributes on the span at once. - - Args: - attributes: Dictionary or list of tuples containing attribute key-value pairs. - Keys that are None or empty will be skipped. - """ - if not self._is_telemetry_enabled() or self._span is None: - return - - # Handle both dict and list of tuples - items = attributes.items() if isinstance(attributes, dict) else attributes - - for key, value in items: - if key and key.strip(): - self._span.set_attribute(key, value) - - def set_end_time(self, end_time: datetime) -> None: - """Set a custom end time for the scope. - - When set, dispose() will pass this value to span.end() instead of using - the current wall-clock time. This is useful when the actual end time of - the operation is known before the scope is disposed. - - Args: - end_time: The end time as a datetime object. - """ - self._custom_end_time = end_time - - def _end(self) -> None: - """End the span and record metrics.""" - if self._span and self._is_telemetry_enabled() and not self._has_ended: - self._has_ended = True - sc = self._span.get_span_context() - span_id = f"{sc.span_id:016x}" if sc else "unknown" - span_name = getattr(self._span, "name", "unknown") - logger.info(f"Span ended: '{span_name}' ({span_id})") - - # Convert custom end time to OTel-compatible format (nanoseconds since epoch) - otel_end_time = self._datetime_to_ns(self._custom_end_time) - if otel_end_time is not None: - self._span.end(end_time=otel_end_time) - else: - self._span.end() - - def get_context(self) -> Context | None: - """Get the OpenTelemetry context for this scope's span. - - This method returns a Context object containing this scope's span, - which can be used to propagate trace context to child operations - or downstream services. - - Returns: - A Context containing this scope's span, or None if telemetry - is disabled or no span exists. - """ - if self._span and self._is_telemetry_enabled(): - return set_span_in_context(self._span) - return None - - def inject_context_to_headers(self) -> dict[str, str]: - """Inject this span's trace context into W3C HTTP headers. - - Returns a dictionary of headers containing ``traceparent`` and - optionally ``tracestate`` that can be forwarded to downstream services - or stored for later context propagation. - - Example usage: - - .. code-block:: python - - scope = OpenTelemetryScope(...) - headers = scope.inject_context_to_headers() - # Add headers to outgoing HTTP request - requests.get("https://downstream-service/api", headers=headers) - - Returns: - A dictionary containing W3C trace context headers. Returns an - empty dictionary if telemetry is disabled or no span exists. - """ - headers: dict[str, str] = {} - if self._span and self._is_telemetry_enabled(): - # Create a context with the current span - ctx = set_span_in_context(self._span) - # Use the global propagator to inject trace context into headers - inject(headers, context=ctx) - return headers - - def __enter__(self): - """Enter the context manager and make span active.""" - if self._span and self._is_telemetry_enabled(): - # Make this span the active span in the current context - new_context = set_span_in_context(self._span) - self._context_token = context.attach(new_context) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Exit the context manager and restore previous context.""" - try: - if exc_type is not None and exc_val is not None: - self.record_error(exc_val) - finally: - # Restore previous context - if self._context_token is not None: - context.detach(self._context_token) - self._end() - - def dispose(self) -> None: - """Dispose the scope and finalize telemetry data collection.""" - self._end() diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py deleted file mode 100644 index b351ca5d..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/request.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Request class. - -from __future__ import annotations - -from dataclasses import dataclass - -from .channel import Channel -from .models.messages import InputMessagesParam - - -@dataclass -class Request: - """Request details for agent execution.""" - - content: InputMessagesParam | None = None - session_id: str | None = None - channel: Channel | None = None - conversation_id: str | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py deleted file mode 100644 index 12710e14..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/span_details.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import dataclass -from datetime import datetime - -from opentelemetry.context import Context -from opentelemetry.trace import Link, SpanKind - - -@dataclass -class SpanDetails: - """Groups span configuration for scope construction.""" - - span_kind: SpanKind | None = None - """Optional span kind override.""" - - parent_context: Context | None = None - """Optional OpenTelemetry Context used to link this span to an upstream operation.""" - - start_time: datetime | None = None - """Optional explicit start time as a datetime object.""" - - end_time: datetime | None = None - """Optional explicit end time as a datetime object.""" - - span_links: list[Link] | None = None - """Optional span links to associate with this span for causal relationships.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py deleted file mode 100644 index db837620..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from opentelemetry.trace import SpanKind - -from ..agent_details import AgentDetails -from ..constants import ( - GEN_AI_CALLER_CLIENT_IP_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) -from ..message_utils import normalize_output_messages, serialize_messages -from ..models.messages import OutputMessages -from ..models.response import Response, ResponseMessagesParam -from ..models.user_details import UserDetails -from ..opentelemetry_scope import OpenTelemetryScope -from ..request import Request -from ..span_details import SpanDetails -from ..utils import safe_json_dumps, validate_and_normalize_ip - -OUTPUT_OPERATION_NAME = "output_messages" - - -class OutputScope(OpenTelemetryScope): - """Provides OpenTelemetry tracing scope for output messages. - - Output messages are set once (via the constructor or ``record_output_messages``) - rather than accumulated. For streaming scenarios, the agent developer should - collect all output (e.g. via a list or string builder) and pass the final - result to ``OutputScope``. - """ - - @staticmethod - def start( - request: Request, - response: Response, - agent_details: AgentDetails, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ) -> "OutputScope": - """Creates and starts a new scope for output tracing. - - Args: - request: Request details for the output - response: The response details from the agent - agent_details: The details of the agent - user_details: Optional human user details - span_details: Optional span configuration (parent context, timing) - - Returns: - A new OutputScope instance - """ - return OutputScope(request, response, agent_details, user_details, span_details) - - def __init__( - self, - request: Request, - response: Response, - agent_details: AgentDetails, - user_details: UserDetails | None = None, - span_details: SpanDetails | None = None, - ): - """Initialize the output scope. - - Args: - request: Request details for the output - response: The response details from the agent - agent_details: The details of the agent - user_details: Optional human user details - span_details: Optional span configuration (parent context, timing) - """ - # spanKind for OutputScope is always CLIENT - resolved_span_details = ( - SpanDetails( - span_kind=SpanKind.CLIENT, - parent_context=span_details.parent_context if span_details else None, - start_time=span_details.start_time if span_details else None, - end_time=span_details.end_time if span_details else None, - span_links=span_details.span_links if span_details else None, - ) - if span_details - else SpanDetails(span_kind=SpanKind.CLIENT) - ) - - super().__init__( - operation_name=OUTPUT_OPERATION_NAME, - activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"), - agent_details=agent_details, - span_details=resolved_span_details, - ) - - self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id) - self._set_output(response.messages) - - # Set user details if provided - if user_details: - self.set_tag_maybe(USER_ID_KEY, user_details.user_id) - self.set_tag_maybe(USER_EMAIL_KEY, user_details.user_email) - self.set_tag_maybe(USER_NAME_KEY, user_details.user_name) - self.set_tag_maybe( - GEN_AI_CALLER_CLIENT_IP_KEY, - validate_and_normalize_ip(user_details.user_client_ip), - ) - - def _set_output(self, messages: ResponseMessagesParam) -> None: - """Serialize and set the output messages attribute on the span.""" - if isinstance(messages, dict): - self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages)) - else: - normalized = normalize_output_messages(messages) - wrapper = OutputMessages(messages=list(normalized.messages)) - self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, serialize_messages(wrapper)) - - def record_output_messages(self, messages: ResponseMessagesParam) -> None: - """Records the output messages for telemetry tracking. - - Overwrites any previously set output messages. Accepts a single string, - a list of strings (auto-wrapped as OTEL OutputMessage), a versioned - ``OutputMessages`` wrapper, or a ``dict[str, object]`` for tool call - results (per OTEL spec). - - Args: - messages: String(s), OutputMessages, or dict for tool call results - """ - self._set_output(messages) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py deleted file mode 100644 index b36c577b..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_call_details.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Data class for tool call details. - -from __future__ import annotations - -from dataclasses import dataclass - -from .models.service_endpoint import ServiceEndpoint - - -@dataclass -class ToolCallDetails: - """Details of a tool call made by an agent in the system.""" - - tool_name: str - arguments: dict[str, object] | str | None = None - tool_call_id: str | None = None - description: str | None = None - tool_type: str | None = None - endpoint: ServiceEndpoint | None = None diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py deleted file mode 100644 index d6580f19..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/tool_type.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Tool type enum. - -from enum import Enum - - -class ToolType(Enum): - """Enumeration for different tool types for execute tool contexts.""" - - FUNCTION = "function" - EXTENSION = "extension" - DATASTORE = "datastore" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py deleted file mode 100644 index 18b27d77..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Trace Processors -""" - -from .span_processor import SpanProcessor - -# Export public API -__all__ = [ - # Span processor - "SpanProcessor", -] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py deleted file mode 100644 index 351e45e9..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Span processor for copying OpenTelemetry baggage entries onto spans. - -This implementation assumes `opentelemetry.baggage.get_all` is available with the -signature `get_all(context: Context | None) -> Mapping[str, object]`. - -For every new span: - * Retrieve the current (or parent) context - * Obtain all baggage entries via `baggage.get_all` - * For each (key, value) pair with a truthy value not already present as a span - attribute, add it via `span.set_attribute` - * Never overwrites existing attributes -""" - -from opentelemetry import baggage, context -from opentelemetry.sdk.trace import SpanProcessor as BaseSpanProcessor - -from ..constants import GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME -from .util import COMMON_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES - - -class SpanProcessor(BaseSpanProcessor): - """Span processor that propagates every baggage key/value to span attributes.""" - - def __init__(self): - super().__init__() - - def on_start(self, span, parent_context=None): - ctx = parent_context or context.get_current() - if ctx is None: - return super().on_start(span, parent_context) - - try: - existing = getattr(span, "attributes", {}) or {} - except Exception: - existing = {} - - try: - baggage_map = baggage.get_all(ctx) or {} - except Exception: - baggage_map = {} - - operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) - is_invoke_agent = False - if operation_name == INVOKE_AGENT_OPERATION_NAME: - is_invoke_agent = True - elif isinstance(getattr(span, "name", None), str) and span.name.startswith( - INVOKE_AGENT_OPERATION_NAME - ): - is_invoke_agent = True - - # Build target key set (avoid duplicates). - target_keys = list(COMMON_ATTRIBUTES) - if is_invoke_agent: - # Add invoke-agent-only attributes - for k in INVOKE_AGENT_ATTRIBUTES: - if k not in target_keys: - target_keys.append(k) - - for key in target_keys: - if key in existing: - continue - value = baggage_map.get(key) - if not value: - continue - try: - span.set_attribute(key, value) - except Exception: - continue - - return super().on_start(span, parent_context) - - def on_end(self, span): - super().on_end(span) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py deleted file mode 100644 index 7faeca2a..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from .. import constants as consts - -# Generic / common tracing attributes -COMMON_ATTRIBUTES = [ - consts.TENANT_ID_KEY, - consts.CUSTOM_PARENT_SPAN_ID_KEY, # custom.parent.span.id - consts.CUSTOM_SPAN_NAME_KEY, # custom.span.name - consts.GEN_AI_CONVERSATION_ID_KEY, # gen_ai.conversation.id - consts.GEN_AI_CONVERSATION_ITEM_LINK_KEY, # microsoft.conversation.item.link - consts.GEN_AI_OPERATION_NAME_KEY, # gen_ai.operation.name - consts.GEN_AI_AGENT_ID_KEY, # gen_ai.agent.id - consts.GEN_AI_AGENT_TYPE_KEY, # gen_ai.agent.type - consts.GEN_AI_AGENT_NAME_KEY, # gen_ai.agent.name - consts.GEN_AI_AGENT_DESCRIPTION_KEY, # gen_ai.agent.description - consts.GEN_AI_AGENT_VERSION_KEY, # gen_ai.agent.version - consts.GEN_AI_AGENT_EMAIL_KEY, # microsoft.agent.user.email - consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, # microsoft.a365.agent.blueprint.id - consts.GEN_AI_AGENT_AUID_KEY, # microsoft.agent.user.id - consts.GEN_AI_AGENT_PLATFORM_ID_KEY, # microsoft.a365.agent.platform.id - consts.SESSION_ID_KEY, # microsoft.session.id - consts.SESSION_DESCRIPTION_KEY, # microsoft.session.description - consts.GEN_AI_CALLER_CLIENT_IP_KEY, # client.address - # Channel dimensions - consts.CHANNEL_NAME_KEY, # microsoft.channel.name - consts.CHANNEL_LINK_KEY, # microsoft.channel.link - # User / Caller attributes - consts.USER_ID_KEY, # user.id - consts.USER_NAME_KEY, # user.name - consts.USER_EMAIL_KEY, # user.email - # Service attributes - consts.SERVICE_NAME_KEY, # service.name -] - -# Invoke Agent–specific attributes -INVOKE_AGENT_ATTRIBUTES = [ - # Caller Agent (A2A) attributes - consts.GEN_AI_CALLER_AGENT_ID_KEY, # microsoft.a365.caller.agent.id - consts.GEN_AI_CALLER_AGENT_NAME_KEY, # microsoft.a365.caller.agent.name - consts.GEN_AI_CALLER_AGENT_USER_ID_KEY, # microsoft.a365.caller.agent.user.id - consts.GEN_AI_CALLER_AGENT_EMAIL_KEY, # microsoft.a365.caller.agent.user.email - consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, # microsoft.a365.caller.agent.blueprint.id - consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, # microsoft.a365.caller.agent.platform.id - consts.GEN_AI_CALLER_AGENT_VERSION_KEY, # microsoft.a365.caller.agent.version - # Server address/port for invoke agent target - consts.SERVER_ADDRESS_KEY, # server.address - consts.SERVER_PORT_KEY, # server.port -] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py deleted file mode 100644 index ff6fc9bf..00000000 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ /dev/null @@ -1,269 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import datetime -import functools -import json -import logging -import traceback -import warnings -from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping -from enum import Enum -from importlib.metadata import version -from ipaddress import AddressValueError, ip_address -from threading import RLock -from typing import Any, Generic, TypeVar, cast - -from opentelemetry import context -from opentelemetry.propagate import extract -from opentelemetry.semconv.attributes.exception_attributes import ( - EXCEPTION_MESSAGE, - EXCEPTION_STACKTRACE, -) -from opentelemetry.trace import Span -from opentelemetry.util.types import AttributeValue -from wrapt import ObjectProxy - -from microsoft_agents_a365.observability.core.constants import ERROR_TYPE_KEY - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -def extract_context_from_headers(headers: dict[str, str]) -> context.Context: - """Extract an OpenTelemetry Context from W3C trace HTTP headers. - - Parses ``traceparent`` (and optionally ``tracestate``) headers and returns - an OpenTelemetry Context that can be passed as ``parent_context`` to any - scope's ``start()`` method. - - Args: - headers: Dictionary of HTTP headers containing trace context. - Expected keys include ``traceparent`` and optionally ``tracestate``. - - Returns: - An OpenTelemetry Context containing the extracted trace information. - If no valid trace context is found, returns an empty context. - - Example:: - - .. code-block:: python - - headers = { - "traceparent": "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" - } - parent_context = extract_context_from_headers(headers) - with InferenceScope.start( - details, agent, tenant, parent_context=parent_context - ): - pass - """ - return extract(headers) - - -def get_traceparent(headers: dict[str, str]) -> str | None: - """Return the W3C ``traceparent`` value from a headers dictionary. - - Args: - headers: Dictionary of HTTP headers, typically obtained from - :meth:`microsoft_agents_a365.observability.core.opentelemetry_scope.OpenTelemetryScope.inject_context_to_headers`. - - Returns: - The traceparent string (e.g. - ``"00---"``), or ``None`` if the - key is not present. - - Example:: - - .. code-block:: python - - # Extract traceparent from incoming HTTP request headers - traceparent = get_traceparent(request.headers) - turn_context.turn_state[A365_PARENT_TRACEPARENT_KEY] = traceparent - """ - return headers.get("traceparent") - - -def safe_json_dumps(obj: Any, **kwargs: Any) -> str: - return json.dumps(obj, default=str, ensure_ascii=False, **kwargs) - - -def as_utc_nano(dt: datetime.datetime) -> int: - return int(dt.astimezone(datetime.UTC).timestamp() * 1_000_000_000) - - -KeyType = TypeVar("KeyType") -ValueType = TypeVar("ValueType") - - -def get_first_value( - mapping: Mapping[KeyType, ValueType], keys: Iterable[KeyType] -) -> ValueType | None: - """ - Returns the first non-null value corresponding to an input key, or None if - no non-null value is found. - """ - if not hasattr(mapping, "get"): - return None - return next( - (value for key in keys if (value := mapping.get(key)) is not None), - None, - ) - - -def stop_on_exception( - wrapped: Callable[..., Iterator[tuple[str, Any]]], -) -> Callable[..., Iterator[tuple[str, Any]]]: - def wrapper(*args: Any, **kwargs: Any) -> Iterator[tuple[str, Any]]: - try: - yield from wrapped(*args, **kwargs) - except Exception: - logger.exception("Failed to get attribute.") - - return wrapper - - -def record_exception(span: Span, error: BaseException) -> None: - if isinstance(error, Exception): - span.record_exception(error) - return - exception_type = error.__class__.__name__ - exception_message = str(error) - if not exception_message: - exception_message = repr(error) - attributes: dict[str, AttributeValue] = { - ERROR_TYPE_KEY: exception_type, - EXCEPTION_MESSAGE: exception_message, - } - try: - attributes[EXCEPTION_STACKTRACE] = traceback.format_exc() - except Exception: - logger.exception("Failed to record exception stacktrace.") - span.add_event(name="exception", attributes=attributes) - - -@stop_on_exception -def flatten(key_values: Iterable[tuple[str, Any]]) -> Iterator[tuple[str, AttributeValue]]: - for key, value in key_values: - if value is None: - continue - if isinstance(value, Mapping): - for sub_key, sub_value in flatten(value.items()): - yield f"{key}.{sub_key}", sub_value - elif isinstance(value, list) and any(isinstance(item, Mapping) for item in value): - for index, sub_mapping in enumerate(value): - for sub_key, sub_value in flatten(sub_mapping.items()): - yield f"{key}.{index}.{sub_key}", sub_value - else: - if isinstance(value, Enum): - value = value.value - yield key, value - - -K = TypeVar("K", bound=Hashable) -V = TypeVar("V") - - -class DictWithLock(ObjectProxy, Generic[K, V]): # type: ignore - """ - A wrapped dictionary with lock - """ - - def __init__(self, wrapped: dict[str, V] | None = None) -> None: - super().__init__(wrapped or {}) - self._self_lock = RLock() - - def get(self, key: K) -> V | None: - with self._self_lock: - return cast(V | None, self.__wrapped__.get(key)) - - def pop(self, key: K, *args: Any) -> V | None: - with self._self_lock: - return cast(V | None, self.__wrapped__.pop(key, *args)) - - def __getitem__(self, key: K) -> V: - with self._self_lock: - return cast(V, super().__getitem__(key)) - - def __setitem__(self, key: K, value: V) -> None: - with self._self_lock: - super().__setitem__(key, value) - - def __delitem__(self, key: K) -> None: - with self._self_lock: - super().__delitem__(key) - - -def extract_model_name(span_name: str) -> str | None: - """ - Extract model name from span names like: - - 'chat.completions gpt-4o-mini' -> 'gpt-4o-mini' - - 'chat.completions gpt-3.5-turbo' -> 'gpt-3.5-turbo' - - 'chat.completions' -> None - """ - parts = span_name.split(" ") - - if len(parts) == 2: - return parts[1] - # If we have more than 2 parts, the model name starts from the 3rd part - # Format: "chat.completions model-name" or "chat.completions model-name-with-dashes" - elif len(parts) >= 3: - # Join everything after "chat.completions" to handle model names with spaces/dashes - model_name = " ".join(parts[2:]) - return model_name.strip() - - return None - - -def deprecated(reason: str): - """Decorator to mark functions as deprecated.""" - - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - warnings.warn( - f"{func.__name__}() is deprecated. {reason}", - category=DeprecationWarning, - stacklevel=2, - ) - return func(*args, **kwargs) - - return wrapper - - return decorator - - -def get_sdk_version() -> str: - """Get the SDK version from package metadata. - - Returns: - The SDK version string, or "0.0.0-unknown" if not found - """ - try: - return version("microsoft-agents-a365-observability-core") - except Exception: - return "0.0.0-unknown" - - -def validate_and_normalize_ip(ip_string: str | None) -> str | None: - """Validate and normalize an IP address string. - - Args: - ip_string: The IP address string to validate (IPv4 or IPv6) - - Returns: - The normalized IP address string if valid, None if invalid or None input - - Logs: - Error message if the IP address is invalid - """ - if ip_string is None: - return None - - try: - # Validate and normalize IP address - ip_obj = ip_address(ip_string) - return str(ip_obj) - except (ValueError, AddressValueError): - logger.error(f"Invalid IP address: '{ip_string}'") - return None diff --git a/libraries/microsoft-agents-a365-observability-core/pyproject.toml b/libraries/microsoft-agents-a365-observability-core/pyproject.toml deleted file mode 100644 index e86e51c3..00000000 --- a/libraries/microsoft-agents-a365-observability-core/pyproject.toml +++ /dev/null @@ -1,92 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] -build-backend = "build_backend" - -[project] -name = "microsoft-agents-a365-observability-core" -dynamic = ["version"] -authors = [ - { name = "Microsoft", email = "support@microsoft.com" }, -] -description = "Telemetry, tracing, and monitoring components for AI agents" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", -] -license = {text = "MIT"} -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents"] -dependencies = [ - "opentelemetry-api", - "opentelemetry-sdk", - "opentelemetry-exporter-otlp", - "pydantic", - "typing-extensions", - "wrapt", - "microsoft-agents-a365-runtime", -] - -[project.urls] -Homepage = "https://github.com/microsoft/Agent365-python" -Repository = "https://github.com/microsoft/Agent365-python" -Issues = "https://github.com/microsoft/Agent365-python/issues" -Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-core" - -[project.optional-dependencies] -azure = [ - "azure-monitor-opentelemetry-exporter", - "azure-identity", - "azure-monitor-ingestion", -] -# Modern approach: use OTLP exporter to send to Jaeger (since Jaeger v1.35+ supports OTLP natively) -jaeger = [ - "opentelemetry-exporter-otlp-proto-grpc", -] -dev = [ - "pytest", - "pytest-asyncio", - "ruff", - "black", - "mypy", -] -test = [ - "pytest", - "pytest-asyncio", -] - -[tool.setuptools.packages.find] -where = ["."] -namespaces = true - -[tool.setuptools] -license-files = ["../../LICENSE"] -include-package-data = true - -[tool.setuptools.package-data] -"*" = ["../../LICENSE"] - -[tool.black] -line-length = 100 -target-version = ['py311'] - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint.flake8-copyright] -notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." -min-file-size = 1 - -[tool.mypy] -python_version = "3.11" -strict = true -warn_return_any = true -warn_unused_configs = true \ No newline at end of file diff --git a/libraries/microsoft-agents-a365-observability-core/setup.py b/libraries/microsoft-agents-a365-observability-core/setup.py deleted file mode 100644 index 8fbb2a49..00000000 --- a/libraries/microsoft-agents-a365-observability-core/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import sys -from pathlib import Path -from os import environ -from setuptools import setup - -# Get version from environment variable set by CI/CD -package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") - -# Add versioning helper to path -helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" -sys.path.insert(0, str(helper_path)) - -from setup_utils import get_dynamic_dependencies # noqa: E402 - -# Use exact version matching for internal dependencies: -# - Internal packages get: == current_version (e.g., == 1.2.3) -# - Ensures all SDK packages must be at the same version -# - Prevents incompatibility issues from version mismatches -setup( - version=package_version, - install_requires=get_dynamic_dependencies( - use_exact_match=True, - ), -) diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/README.md b/libraries/microsoft-agents-a365-observability-extensions-agentframework/README.md deleted file mode 100644 index 9b992b69..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# microsoft-agents-a365-observability-extensions-agentframework - -[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-observability-extensions-agentframework?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-agentframework) -[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-observability-extensions-agentframework?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-agentframework) - -Observability extensions for Microsoft Agent Framework. This package provides OpenTelemetry tracing integration specifically for Agent Framework-based applications. - -## Installation - -```bash -pip install microsoft-agents-a365-observability-extensions-agentframework -``` - -## Usage - -For usage examples and detailed documentation, see the [Observability documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=python) on Microsoft Learn. - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details. diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/docs/design.md b/libraries/microsoft-agents-a365-observability-extensions-agentframework/docs/design.md deleted file mode 100644 index 0f90f7b3..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/docs/design.md +++ /dev/null @@ -1,59 +0,0 @@ -# Observability Extensions - Agent Framework - Design Document - -This document describes the architecture and design of the `microsoft-agents-a365-observability-extensions-agentframework` package. - -## Overview - -This extension integrates the Agent365 observability infrastructure with the Microsoft Agents SDK (Agent Framework), enabling automatic tracing of agent operations. - -## Key Components - -### AgentFrameworkTraceInstrumentor - -The main instrumentor class that integrates with Agent Framework operations. - -```python -from microsoft_agents_a365.observability.extensions.agentframework import ( - AgentFrameworkTraceInstrumentor -) - -# Initialize and instrument -instrumentor = AgentFrameworkTraceInstrumentor() -instrumentor.instrument() -``` - -### SpanProcessor - -Custom span processor that captures Agent Framework-specific telemetry. - -### Integration Flow - -``` -Agent Framework Execution - │ - ▼ -TraceInstrumentor (intercepts agent calls) - │ - ▼ -SpanProcessor (creates Agent365 spans) - │ - ▼ -InvokeAgentScope / InferenceScope / ExecuteToolScope - │ - ▼ -Agent365 Backend -``` - -## File Structure - -``` -microsoft_agents_a365/observability/extensions/agentframework/ -├── __init__.py # Public API -├── trace_instrumentor.py # Main instrumentor -└── span_processor.py # Custom span processor -``` - -## Dependencies - -- `agent-framework` - Microsoft Agents SDK -- `microsoft-agents-a365-observability-core` - Core observability diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/__init__.py deleted file mode 100644 index f73f4303..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Agent Framework observability extensions for Agent365.""" - -from .trace_instrumentor import AgentFrameworkInstrumentor - -__all__ = [ - "AgentFrameworkInstrumentor", -] diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/message_mapper.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/message_mapper.py deleted file mode 100644 index 79f3d580..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/message_mapper.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Maps Agent Framework span tag messages to A365 versioned message format. - -Agent Framework sets ``gen_ai.input.messages`` / ``gen_ai.output.messages`` as span -tags containing JSON arrays of ``{role, parts[{type, content}], finish_reason?}``. -This mapper converts them to ``InputMessages`` / ``OutputMessages``. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -from microsoft_agents_a365.observability.core.message_utils import serialize_messages -from microsoft_agents_a365.observability.core.models.messages import ( - BlobPart, - ChatMessage, - FilePart, - GenericPart, - InputMessages, - MessagePart, - MessageRole, - OutputMessage, - OutputMessages, - ReasoningPart, - TextPart, - ToolCallRequestPart, - ToolCallResponsePart, - UriPart, -) - -logger = logging.getLogger(__name__) - -_ROLE_MAP: dict[str, MessageRole] = { - "system": MessageRole.SYSTEM, - "user": MessageRole.USER, - "assistant": MessageRole.ASSISTANT, - "tool": MessageRole.TOOL, -} - - -def map_input_messages(messages_json: str) -> str | None: - """Map a ``gen_ai.input.messages`` tag value to a serialized A365 JSON string. - - Args: - messages_json: The raw JSON string from the span attribute. - - Returns: - Serialized ``InputMessages`` JSON string, or ``None`` if the - input is empty or cannot be parsed. - """ - try: - raw = json.loads(messages_json) - except (json.JSONDecodeError, TypeError): - logger.debug("Failed to parse input messages JSON: %s", messages_json[:200]) - return None - - if not isinstance(raw, list): - return None - - chat_messages: list[ChatMessage] = [] - for msg in raw: - if not isinstance(msg, dict): - continue - role = _map_role(msg.get("role"), MessageRole.USER) - parts = _map_parts(msg) - if parts: - chat_messages.append(ChatMessage(role=role, parts=parts, name=msg.get("name"))) - - if not chat_messages: - return None - - return serialize_messages(InputMessages(messages=chat_messages)) - - -def map_output_messages(messages_json: str) -> str | None: - """Map a ``gen_ai.output.messages`` tag value to a serialized A365 JSON string. - - Args: - messages_json: The raw JSON string from the span attribute. - - Returns: - Serialized ``OutputMessages`` JSON string, or ``None`` if the - input is empty or cannot be parsed. - """ - try: - raw = json.loads(messages_json) - except (json.JSONDecodeError, TypeError): - logger.debug("Failed to parse output messages JSON: %s", messages_json[:200]) - return None - - if not isinstance(raw, list): - return None - - output_messages: list[OutputMessage] = [] - for msg in raw: - if not isinstance(msg, dict): - continue - role = _map_role(msg.get("role"), MessageRole.ASSISTANT) - parts = _map_parts(msg) - finish_reason = msg.get("finish_reason") - if parts: - output_messages.append( - OutputMessage(role=role, parts=parts, finish_reason=finish_reason) - ) - - if not output_messages: - return None - - return serialize_messages(OutputMessages(messages=output_messages)) - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - - -def _map_role(role: str | None, default: MessageRole) -> MessageRole: - """Map a raw role string to a ``MessageRole`` enum.""" - if not role: - return default - return _ROLE_MAP.get(role.lower(), default) - - -def _map_parts(msg: dict[str, Any]) -> list[MessagePart]: - """Map all parts in a raw message dict.""" - parts_data = msg.get("parts", []) - if not isinstance(parts_data, list): - return [] - mapped = [_map_single_part(p) for p in parts_data if isinstance(p, dict)] - return [p for p in mapped if p is not None] - - -def _map_single_part(part: dict[str, Any]) -> MessagePart | None: - """Map a single raw part dict to the appropriate A365 message part.""" - part_type = part.get("type", "") - - if part_type == "text": - content = part.get("content", "") - return TextPart(content=content) if content else None - - if part_type == "reasoning": - content = part.get("content", "") - return ReasoningPart(content=content) if content else None - - if part_type == "tool_call": - name = part.get("name") - if not name: - return None - return ToolCallRequestPart( - name=name, - id=part.get("id"), - arguments=part.get("arguments"), - ) - - if part_type == "tool_call_response": - return ToolCallResponsePart( - id=part.get("id"), - response=part.get("response"), - ) - - if part_type == "blob": - modality = part.get("modality", "") - content = part.get("content", "") - if not modality or not content: - return None - return BlobPart(modality=modality, content=content, mime_type=part.get("mime_type")) - - if part_type == "file": - modality = part.get("modality", "") - file_id = part.get("file_id", "") - if not modality or not file_id: - return None - return FilePart(modality=modality, file_id=file_id, mime_type=part.get("mime_type")) - - if part_type == "uri": - modality = part.get("modality", "") - uri = part.get("uri", "") - if not modality or not uri: - return None - return UriPart(modality=modality, uri=uri, mime_type=part.get("mime_type")) - - # Fallback: GenericPart for unknown/future part types - data = {k: v for k, v in part.items() if k != "type"} - return GenericPart(type=part_type, data=data) if part_type else None diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_enricher.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_enricher.py deleted file mode 100644 index ed9d4bfa..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_enricher.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from microsoft_agents_a365.observability.core.constants import ( - CHAT_OPERATION_NAME, - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_TOOL_ARGS_KEY, - GEN_AI_TOOL_CALL_RESULT_KEY, - INVOKE_AGENT_OPERATION_NAME, -) -from microsoft_agents_a365.observability.core.exporters.enriched_span import EnrichedReadableSpan -from opentelemetry.sdk.trace import ReadableSpan - -from .message_mapper import map_input_messages, map_output_messages - -# Agent Framework specific attribute keys -AF_TOOL_CALL_ARGUMENTS_KEY = "gen_ai.tool.call.arguments" -AF_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result" - -_MESSAGE_OPERATIONS = {INVOKE_AGENT_OPERATION_NAME, CHAT_OPERATION_NAME} - - -def enrich_agent_framework_span(span: ReadableSpan) -> ReadableSpan: - """Enricher function for Agent Framework spans. - - For ``invoke_agent`` and ``chat`` operations, maps the raw - ``gen_ai.input.messages`` / ``gen_ai.output.messages`` JSON arrays - to the A365 versioned format. - - For ``execute_tool`` operations, maps Agent Framework tool attribute - keys to the A365 standard keys. - """ - extra_attributes: dict[str, str] = {} - attributes = span.attributes or {} - operation = attributes.get(GEN_AI_OPERATION_NAME_KEY, "") - - is_message_span = operation in _MESSAGE_OPERATIONS or span.name.startswith( - INVOKE_AGENT_OPERATION_NAME - ) - is_tool_span = operation == EXECUTE_TOOL_OPERATION_NAME or span.name.startswith( - EXECUTE_TOOL_OPERATION_NAME - ) - - if is_message_span: - input_messages = attributes.get(GEN_AI_INPUT_MESSAGES_KEY) - if input_messages: - mapped = map_input_messages(input_messages) - if mapped is not None: - extra_attributes[GEN_AI_INPUT_MESSAGES_KEY] = mapped - - output_messages = attributes.get(GEN_AI_OUTPUT_MESSAGES_KEY) - if output_messages: - mapped = map_output_messages(output_messages) - if mapped is not None: - extra_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] = mapped - - elif is_tool_span: - if AF_TOOL_CALL_ARGUMENTS_KEY in attributes: - extra_attributes[GEN_AI_TOOL_ARGS_KEY] = attributes[AF_TOOL_CALL_ARGUMENTS_KEY] - - if AF_TOOL_CALL_RESULT_KEY in attributes: - extra_attributes[GEN_AI_TOOL_CALL_RESULT_KEY] = attributes[AF_TOOL_CALL_RESULT_KEY] - - if extra_attributes: - return EnrichedReadableSpan(span, extra_attributes) - - return span diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_processor.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_processor.py deleted file mode 100644 index 09c03f3e..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/span_processor.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from opentelemetry.sdk.trace.export import SpanProcessor - - -class AgentFrameworkSpanProcessor(SpanProcessor): - """SpanProcessor for Agent Framework. - - Attribute mutation happens in the enricher (via - :class:`microsoft_agents_a365.observability.core.exporters.enriched_span.EnrichedReadableSpan`) - because OTel Python ``ReadableSpan`` is immutable after ``on_end``. - The enricher is invoked at export time by the ``EnrichingSpanProcessor``. - """ - - def __init__(self, service_name: str | None = None): - self.service_name = service_name - super().__init__() - - def on_start(self, span, parent_context): - """Called when a span starts. Intentionally a no-op.""" - pass - - def on_end(self, span): - """Called when a span ends. Intentionally a no-op. - - Message mapping is handled by the span enricher at export time - since ReadableSpan is immutable in the Python OTel SDK. - """ - pass diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/trace_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/trace_instrumentor.py deleted file mode 100644 index a241018f..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/trace_instrumentor.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -from collections.abc import Collection -from typing import Any - -from microsoft_agents_a365.observability.core.config import get_tracer_provider, is_configured -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - register_span_enricher, - unregister_span_enricher, -) -from opentelemetry.instrumentation.instrumentor import BaseInstrumentor - -from microsoft_agents_a365.observability.extensions.agentframework.span_enricher import ( - enrich_agent_framework_span, -) -from microsoft_agents_a365.observability.extensions.agentframework.span_processor import ( - AgentFrameworkSpanProcessor, -) - -# ----------------------------- -# 3) The Instrumentor class -# ----------------------------- -_instruments = ("agent-framework >= 1.0.0",) - - -class AgentFrameworkInstrumentor(BaseInstrumentor): - """ - Instruments Agent Framework with Agent365 observability. - """ - - def __init__(self): - if not is_configured(): - raise RuntimeError( - "Microsoft Agent 365 (or your telemetry config) is not initialized. Configure it before instrumenting." - ) - super().__init__() - - def instrumentation_dependencies(self) -> Collection[str]: - return _instruments - - def _instrument(self, **kwargs: Any) -> None: - """ - Instrument Agent Framework. - - Args: - **kwargs: Optional configuration parameters. - """ - # Ensure we have an SDK TracerProvider - provider = get_tracer_provider() - - # Add processor for on_start modifications (rename spans, add attributes) - self._processor = AgentFrameworkSpanProcessor() - provider.add_span_processor(self._processor) - - # Register enricher for on_end modifications - register_span_enricher(enrich_agent_framework_span) - - def _uninstrument(self, **kwargs: Any) -> None: - """ - Remove Agent Framework instrumentation. - """ - # Unregister the enricher - unregister_span_enricher() - - # Shutdown the processor - if hasattr(self, "_processor"): - self._processor.shutdown() diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/utils.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/utils.py deleted file mode 100644 index 84f7da1e..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/microsoft_agents_a365/observability/extensions/agentframework/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Utility functions for Agent Framework observability extensions.""" - -from __future__ import annotations - -import json - - -def extract_content_as_string_list(messages_json: str, role_filter: str | None = None) -> str: - """Extract content values from messages JSON and return as JSON string list.""" - try: - messages = json.loads(messages_json) - if isinstance(messages, list): - contents = [] - for msg in messages: - if isinstance(msg, dict): - role = msg.get("role", "") - - # Filter by role if specified - if role_filter and role != role_filter: - continue - - # Handle Agent Framework format with "parts" - parts = msg.get("parts") - if parts and isinstance(parts, list): - for part in parts: - if isinstance(part, dict): - part_type = part.get("type", "") - # Only extract text content, not tool_call or tool_call_response - if part_type == "text" and "content" in part: - contents.append(part["content"]) - return json.dumps(contents) - return messages_json - except (json.JSONDecodeError, TypeError): - # If parsing fails, return as-is - return messages_json - - -def extract_input_content(messages_json: str) -> str: - """Extract text content from user messages only.""" - return extract_content_as_string_list(messages_json, role_filter="user") - - -def extract_output_content(messages_json: str) -> str: - """Extract only assistant text content from output messages.""" - return extract_content_as_string_list(messages_json, role_filter="assistant") diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml deleted file mode 100644 index 924cd027..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml +++ /dev/null @@ -1,79 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] -build-backend = "build_backend" - -[project] -name = "microsoft-agents-a365-observability-extensions-agent-framework" -dynamic = ["version"] -authors = [ - { name = "Microsoft", email = "support@microsoft.com" }, -] -description = "Agent Framework observability and tracing extensions for Microsoft Agent 365" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", -] -license = "MIT" -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "agent-framework", "agents", "ai"] -dependencies = [ - "microsoft-agents-a365-observability-core", - "opentelemetry-api", - "opentelemetry-sdk", - "opentelemetry-instrumentation", -] - -[project.urls] -Homepage = "https://github.com/microsoft/Agent365-python" -Repository = "https://github.com/microsoft/Agent365-python" -Issues = "https://github.com/microsoft/Agent365-python/issues" -Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-extensions-agentframework" - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "ruff", - "black", - "mypy", -] -test = [ - "pytest", - "pytest-asyncio", -] - -[tool.setuptools.packages.find] -where = ["."] - -[tool.setuptools] -license-files = ["../../LICENSE"] -include-package-data = true - -[tool.setuptools.package-data] -"*" = ["../../LICENSE"] - -[tool.black] -line-length = 100 -target-version = ['py311'] - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint.flake8-copyright] -notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." -min-file-size = 1 - -[tool.mypy] -python_version = "3.11" -strict = true -warn_return_any = true -warn_unused_configs = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py b/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py deleted file mode 100644 index 8fbb2a49..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import sys -from pathlib import Path -from os import environ -from setuptools import setup - -# Get version from environment variable set by CI/CD -package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") - -# Add versioning helper to path -helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" -sys.path.insert(0, str(helper_path)) - -from setup_utils import get_dynamic_dependencies # noqa: E402 - -# Use exact version matching for internal dependencies: -# - Internal packages get: == current_version (e.g., == 1.2.3) -# - Ensures all SDK packages must be at the same version -# - Prevents incompatibility issues from version mismatches -setup( - version=package_version, - install_requires=get_dynamic_dependencies( - use_exact_match=True, - ), -) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/README.md b/libraries/microsoft-agents-a365-observability-extensions-langchain/README.md deleted file mode 100644 index 9b63a953..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# microsoft-agents-a365-observability-extensions-langchain - -[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-observability-extensions-langchain?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-langchain) -[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-observability-extensions-langchain?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-langchain) - -Observability extensions for LangChain framework. This package provides OpenTelemetry tracing integration for LangChain-based AI applications with automatic instrumentation for chains, agents, and tools. - -## Installation - -```bash -pip install microsoft-agents-a365-observability-extensions-langchain -``` - -## Usage - -For usage examples and detailed documentation, see the [Observability documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=python) on Microsoft Learn. - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details. diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/docs/design.md b/libraries/microsoft-agents-a365-observability-extensions-langchain/docs/design.md deleted file mode 100644 index fc848fdd..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/docs/design.md +++ /dev/null @@ -1,69 +0,0 @@ -# Observability Extensions - LangChain - Design Document - -This document describes the architecture and design of the `microsoft-agents-a365-observability-extensions-langchain` package. - -## Overview - -This extension integrates the Agent365 observability infrastructure with LangChain, enabling automatic tracing of LangChain chain executions, LLM calls, and tool invocations. - -## Key Components - -### LangChainTracerInstrumentor - -The main instrumentor class that integrates with LangChain's callback system. - -```python -from microsoft_agents_a365.observability.extensions.langchain import LangChainTracerInstrumentor - -# Initialize and instrument -instrumentor = LangChainTracerInstrumentor() -instrumentor.instrument() -``` - -### Tracer Callback - -Custom LangChain callback handler that creates Agent365 spans for: -- Chain executions -- LLM calls (with token tracking) -- Tool executions - -### Integration Flow - -``` -LangChain Execution - │ - ▼ -Callback Handler (on_llm_start, on_tool_start, etc.) - │ - ▼ -InferenceScope / ExecuteToolScope - │ - ▼ -Agent365 Backend -``` - -## File Structure - -``` -microsoft_agents_a365/observability/extensions/langchain/ -├── __init__.py # Public API -├── tracer.py # LangChain tracer callback -├── tracer_instrumentor.py # Main instrumentor -└── utils.py # Utility functions -``` - -## LangChain Callback Events - -| Event | Agent365 Scope | -|-------|----------------| -| `on_llm_start` | `InferenceScope` | -| `on_llm_end` | Records tokens, finish reason | -| `on_tool_start` | `ExecuteToolScope` | -| `on_tool_end` | Records tool result | -| `on_chain_start` | `InvokeAgentScope` | -| `on_chain_end` | Records chain output | - -## Dependencies - -- `langchain-core` - LangChain core framework (tracers, callbacks, message types) -- `microsoft-agents-a365-observability-core` - Core observability diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py deleted file mode 100644 index 2c25c81b..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Wraps the Langchain Agents SDK tracer to integrate with our Telemetry Solution. -""" - -from .tracer_instrumentor import CustomLangChainInstrumentor - -__all__ = ["CustomLangChainInstrumentor"] - -# This is a namespace package -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/message_mapper.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/message_mapper.py deleted file mode 100644 index 73c21d1a..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/message_mapper.py +++ /dev/null @@ -1,240 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Maps LangChain messages to A365 versioned message format. - -LangChain provides ``BaseMessage`` objects (``HumanMessage``, ``AIMessage``, -``SystemMessage``, ``ToolMessage``) in ``run.inputs["messages"]`` and -``run.outputs["generations"]``. This mapper converts them to the A365 -versioned format (``InputMessages`` / ``OutputMessages``). -""" - -from __future__ import annotations - -import logging -from collections.abc import Iterable, Mapping -from typing import Any - -from langchain_core.messages import BaseMessage - -from microsoft_agents_a365.observability.core.message_utils import serialize_messages -from microsoft_agents_a365.observability.core.models.messages import ( - ChatMessage, - InputMessages, - MessagePart, - MessageRole, - OutputMessage, - OutputMessages, - TextPart, - ToolCallRequestPart, - ToolCallResponsePart, -) - -logger = logging.getLogger(__name__) - -_ROLE_MAP: dict[str, MessageRole] = { - "human": MessageRole.USER, - "user": MessageRole.USER, - "ai": MessageRole.ASSISTANT, - "assistant": MessageRole.ASSISTANT, - "system": MessageRole.SYSTEM, - "tool": MessageRole.TOOL, -} - - -def map_input_messages(inputs: Mapping[str, Any] | None) -> str | None: - """Map LangChain input messages to a serialized A365 InputMessages JSON string. - - Args: - inputs: The ``run.inputs`` mapping from a LangChain run. - - Returns: - Serialized InputMessages JSON string, or None if no messages found. - """ - if not inputs or not isinstance(inputs, Mapping): - return None - - multiple_messages = inputs.get("messages") - if not multiple_messages or not isinstance(multiple_messages, Iterable): - return None - - first_messages = next(iter(multiple_messages), None) - if not first_messages: - return None - - # Normalize to a list - if isinstance(first_messages, BaseMessage): - first_messages = [first_messages] - elif not isinstance(first_messages, list): - return None - - chat_messages: list[ChatMessage] = [] - for msg in first_messages: - mapped = _map_base_message(msg) - if mapped is not None: - chat_messages.append(mapped) - - if not chat_messages: - return None - - return serialize_messages(InputMessages(messages=chat_messages)) - - -def map_output_messages(outputs: Mapping[str, Any] | None) -> str | None: - """Map LangChain output messages to a serialized A365 OutputMessages JSON string. - - Args: - outputs: The ``run.outputs`` mapping from a LangChain run. - - Returns: - Serialized OutputMessages JSON string, or None if no messages found. - """ - if not outputs or not isinstance(outputs, Mapping): - return None - - multiple_generations = outputs.get("generations") - if not multiple_generations or not isinstance(multiple_generations, Iterable): - return None - - first_generations = next(iter(multiple_generations), None) - if not first_generations or not isinstance(first_generations, Iterable): - return None - - output_messages: list[OutputMessage] = [] - for generation in first_generations: - if not isinstance(generation, Mapping): - continue - message_data = generation.get("message") - if message_data is None: - continue - - mapped = _map_to_output_message(message_data, generation) - if mapped is not None: - output_messages.append(mapped) - - if not output_messages: - return None - - return serialize_messages(OutputMessages(messages=output_messages)) - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - - -def _map_role( - msg: BaseMessage | Mapping[str, Any], default: MessageRole = MessageRole.USER -) -> MessageRole: - """Extract the role from a LangChain message.""" - if isinstance(msg, BaseMessage): - role_str = msg.type - elif isinstance(msg, Mapping): - # Direct type field (e.g. "human", "ai", "system", "tool") - role_str = msg.get("type", "") - # LC serialization uses "constructor" as type with role in kwargs - if role_str == "constructor": - kwargs = msg.get("kwargs", {}) - role_str = kwargs.get("type", "") if isinstance(kwargs, Mapping) else "" - # Also check "role" field - if not role_str or role_str not in _ROLE_MAP: - role_str = msg.get("role", role_str) - else: - role_str = "" - return _ROLE_MAP.get(role_str.lower(), default) - - -def _extract_parts(msg: BaseMessage | Mapping[str, Any]) -> list[MessagePart]: - """Extract message parts from a LangChain message.""" - parts: list[MessagePart] = [] - - # Extract content and tool_calls - if isinstance(msg, BaseMessage): - content = msg.content - tool_calls = getattr(msg, "tool_calls", None) - msg_type = msg.type - tool_call_id = getattr(msg, "tool_call_id", None) - elif isinstance(msg, Mapping): - # Handle LC serialization: {"type": "constructor", "kwargs": {content, type, ...}} - kwargs = msg.get("kwargs", {}) if msg.get("type") == "constructor" else msg - if not isinstance(kwargs, Mapping): - kwargs = msg - content = kwargs.get("content", "") or msg.get("content", "") - tool_calls = kwargs.get("tool_calls") or msg.get("tool_calls") - msg_type = kwargs.get("type", "") or msg.get("type", "") - tool_call_id = kwargs.get("tool_call_id") or msg.get("tool_call_id") - else: - return parts - - # Tool response (from ToolMessage) — handle before text to avoid double-counting - if msg_type == "tool": - response = content if isinstance(content, str) else str(content) if content else "" - if response or tool_call_id: - parts.append(ToolCallResponsePart(id=tool_call_id, response=response)) - return parts - - # Text content - if content and isinstance(content, str) and content.strip(): - parts.append(TextPart(content=content)) - - # Tool calls (from AIMessage.tool_calls) - if tool_calls and isinstance(tool_calls, list): - for tc in tool_calls: - if not isinstance(tc, Mapping): - continue - name = tc.get("name") - if not name: - continue - args = tc.get("args") - args_str = None - if args is not None: - import json - - try: - args_str = json.dumps(args) if not isinstance(args, str) else args - except (TypeError, ValueError): - logger.debug("Failed to serialize tool call args for '%s': %s", name, args) - args_str = str(args) - - parts.append( - ToolCallRequestPart( - name=name, - id=tc.get("id"), - arguments=args_str, - ) - ) - - return parts - - -def _map_base_message(msg: BaseMessage | Mapping[str, Any]) -> ChatMessage | None: - """Map a single LangChain message to an A365 ChatMessage.""" - role = _map_role(msg) - parts = _extract_parts(msg) - if not parts: - return None - - name = None - if isinstance(msg, BaseMessage): - name = getattr(msg, "name", None) - - return ChatMessage(role=role, parts=parts, name=name) - - -def _map_to_output_message( - message_data: BaseMessage | Mapping[str, Any], - generation: Mapping[str, Any], -) -> OutputMessage | None: - """Map a LangChain generation to an A365 OutputMessage.""" - role = _map_role(message_data, default=MessageRole.ASSISTANT) - parts = _extract_parts(message_data) - if not parts: - return None - - # Extract finish_reason from generation metadata - finish_reason = None - gen_info = generation.get("generation_info") - if isinstance(gen_info, Mapping): - finish_reason = gen_info.get("finish_reason") - - return OutputMessage(role=role, parts=parts, finish_reason=finish_reason) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py deleted file mode 100644 index 4cbc2a00..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer.py +++ /dev/null @@ -1,279 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import logging -import re -from collections import OrderedDict -from collections.abc import Iterator -from itertools import chain -from threading import RLock -from typing import ( - TYPE_CHECKING, - Any, - cast, -) -from uuid import UUID - -from langchain_core.tracers import BaseTracer, LangChainTracer -from langchain_core.tracers.schemas import Run -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_AGENT_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, -) -from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType -from microsoft_agents_a365.observability.core.utils import ( - DictWithLock, - as_utc_nano, - flatten, - record_exception, -) -from opentelemetry import context as context_api -from opentelemetry import trace as trace_api -from opentelemetry.context import ( - _SUPPRESS_INSTRUMENTATION_KEY, - get_value, -) -from opentelemetry.trace import Span -from opentelemetry.util.types import AttributeValue - -from microsoft_agents_a365.observability.extensions.langchain.utils import ( - IGNORED_EXCEPTION_PATTERNS, - add_operation_type, - function_calls, - input_messages, - invocation_parameters, - invoke_agent_input_message, - invoke_agent_output_message, - llm_provider, - metadata, - model_name, - output_messages, - prompts, - token_counts, - tools, -) - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -CONTEXT_ATTRIBUTES = ( - "session.id", - "user.id", - "metadata", - "tag.tags", - "llm.prompt_template.template", - "llm.prompt_template.variables", - "llm.prompt_template.version", -) - - -class CustomLangChainTracer(BaseTracer): - _MAX_TRACKED_RUNS = 10000 - - __slots__ = ( - "_tracer", - "_separate_trace_from_runtime_context", - "_spans_by_run", - ) - - def __init__( - self, - tracer: trace_api.Tracer, - separate_trace_from_runtime_context: bool, - *args: Any, - **kwargs: Any, - ) -> None: - """Initialize the CustomLangChainTracer. - - Args: - tracer: The OpenTelemetry tracer for creating spans. - separate_trace_from_runtime_context: When True, always start a new - trace for each span without a parent, isolating it from any - existing trace in the runtime context. - *args: Positional arguments forwarded to BaseTracer. - **kwargs: Keyword arguments forwarded to BaseTracer. - """ - super().__init__(*args, **kwargs) - if TYPE_CHECKING: - # check that `run_map` still exists in parent class - assert self.run_map - self.run_map = DictWithLock[str, Run](self.run_map) - self._tracer = tracer - self._separate_trace_from_runtime_context = separate_trace_from_runtime_context - self._spans_by_run: OrderedDict[UUID, Span] = OrderedDict() - self._lock = RLock() # handlers may be run in a thread by langchain - - def get_span(self, run_id: UUID) -> Span | None: - with self._lock: - return self._spans_by_run.get(run_id) - - @staticmethod - def _cap_ordered_dict(d: OrderedDict, max_size: int) -> None: - """Evict oldest entries from an OrderedDict to stay within max_size.""" - while len(d) > max_size: - d.popitem(last=False) - - def _start_trace(self, run: Run) -> None: - self.run_map[str(run.id)] = run - if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): - return - with self._lock: - parent_context = ( - trace_api.set_span_in_context(parent) - if (parent_run_id := run.parent_run_id) - and (parent := self._spans_by_run.get(parent_run_id)) - else (context_api.Context() if self._separate_trace_from_runtime_context else None) - ) - # We can't use real time because the handler may be - # called in a background thread. - start_time_utc_nano = as_utc_nano(run.start_time) - - # Determine span name based on run type - if run.run_type == "chain" and run.name == "LangGraph": - span_name = f"invoke_agent {run.name}" - elif run.run_type.lower() == "tool": - span_name = f"execute_tool {run.name}" - else: - span_name = run.name - - span = self._tracer.start_span( - name=span_name, - context=parent_context, - start_time=start_time_utc_nano, - ) - - # The following line of code is commented out to serve as a reminder that in a system - # of callbacks, attaching the context can be hazardous because there is no guarantee - # that the context will be detached. An error could happen between callbacks leaving - # the context attached forever, and all future spans will use it as parent. What's - # worse is that the error could have also prevented the span from being exported, - # leaving all future spans as orphans. That is a very bad scenario. - # token = context_api.attach(context) - with self._lock: - self._spans_by_run[run.id] = span - self._cap_ordered_dict(self._spans_by_run, self._MAX_TRACKED_RUNS) - - def _end_trace(self, run: Run) -> None: - self.run_map.pop(str(run.id), None) - if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): - return - with self._lock: - span = self._spans_by_run.pop(run.id, None) - if span: - try: - _update_span(span, run) - except Exception: - logger.exception("Failed to update span with run data.") - # We can't use real time because the handler may be - # called in a background thread. - end_time_utc_nano = as_utc_nano(run.end_time) if run.end_time else None - span.end(end_time=end_time_utc_nano) - - def _persist_run(self, run: Run) -> None: - pass - - def on_llm_error(self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any) -> Run: - with self._lock: - span = self._spans_by_run.get(run_id) - if span: - record_exception(span, error) - return super().on_llm_error(error, *args, run_id=run_id, **kwargs) - - def on_chain_error(self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any) -> Run: - with self._lock: - span = self._spans_by_run.get(run_id) - if span: - record_exception(span, error) - return super().on_chain_error(error, *args, run_id=run_id, **kwargs) - - def on_retriever_error( - self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any - ) -> Run: - with self._lock: - span = self._spans_by_run.get(run_id) - if span: - record_exception(span, error) - return super().on_retriever_error(error, *args, run_id=run_id, **kwargs) - - def on_tool_error(self, error: BaseException, *args: Any, run_id: UUID, **kwargs: Any) -> Run: - with self._lock: - span = self._spans_by_run.get(run_id) - if span: - record_exception(span, error) - return super().on_tool_error(error, *args, run_id=run_id, **kwargs) - - def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run: - """Handle chat model start events. - - Delegates to ``LangChainTracer.on_chat_model_start`` to obtain correct - chat-formatted spans. The ``BaseTracer`` implementation requires setting - an internal ``_schema_format`` flag; delegating to ``LangChainTracer`` - avoids depending on that private attribute. - """ - return LangChainTracer.on_chat_model_start(self, *args, **kwargs) # type: ignore - - -def get_attributes_from_context() -> Iterator[tuple[str, AttributeValue]]: - for ctx_attr in CONTEXT_ATTRIBUTES: - if (val := get_value(ctx_attr)) is not None: - yield ctx_attr, cast(AttributeValue, val) - - -def _update_span(span: Span, run: Run) -> None: - # If there is no error or if there is an agent control exception, set the span to OK - if run.error is None or any( - re.match(pattern, run.error) for pattern in IGNORED_EXCEPTION_PATTERNS - ): - span.set_status(trace_api.StatusCode.OK) - else: - span.set_status(trace_api.Status(trace_api.StatusCode.ERROR, run.error)) - - span.set_attributes(dict(get_attributes_from_context())) - - if run.run_type == "llm" and run.outputs.get("llm_output").get("id").startswith("chat"): - span.update_name(f"{InferenceOperationType.CHAT.value.lower()} {span.name}") - is_invoke_agent = span.name.startswith(INVOKE_AGENT_OPERATION_NAME) - - # If this is an invoke_agent span, update span name with agent name - if is_invoke_agent: - agent_name = None - if hasattr(span, "_attributes") and span._attributes: - agent_name = span._attributes.get(GEN_AI_AGENT_NAME_KEY) - if agent_name: - span.update_name(f"{INVOKE_AGENT_OPERATION_NAME} {agent_name}") - - # For invoke_agent spans, add input/output messages - if is_invoke_agent: - span.set_attributes( - dict( - flatten( - chain( - add_operation_type(run), - invoke_agent_input_message(run.inputs), - invoke_agent_output_message(run.outputs), - metadata(run), - ) - ) - ) - ) - else: - span.set_attributes( - dict( - flatten( - chain( - add_operation_type(run), - prompts(run.inputs), - input_messages(run.inputs), - output_messages(run.outputs), - invocation_parameters(run), - llm_provider(run.extra), - model_name(run.outputs, run.extra), - token_counts(run.outputs), - function_calls(run.outputs), - tools(run), - metadata(run), - ) - ) - ) - ) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py deleted file mode 100644 index 948ee59c..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -import logging -from collections.abc import Callable, Collection -from typing import Any -from uuid import UUID - -import langchain_core -import opentelemetry.trace as optel_trace -from langchain_core.callbacks import BaseCallbackManager -from microsoft_agents_a365.observability.core.config import ( - get_tracer, - get_tracer_provider, - is_configured, -) -from opentelemetry.instrumentation.instrumentor import BaseInstrumentor -from opentelemetry.trace import Span -from wrapt import wrap_function_wrapper - -from microsoft_agents_a365.observability.extensions.langchain.tracer import CustomLangChainTracer - -logger = logging.getLogger(__name__) - -_INSTRUMENTS: str = "langchain_core >= 1.2.0" - - -class CustomLangChainInstrumentor(BaseInstrumentor): - """ - Minimal instrumentor that attaches a TraceForLangChain to every new - LangChain BaseCallbackManager so runs produce OpenTelemetry spans. - """ - - def __init__(self) -> None: - if not is_configured(): - raise RuntimeError( - "Tracing SDK is not configured. Configure it before using this instrumentor." - ) - super().__init__() - self._tracer: CustomLangChainTracer | None = None - self._original_cb_init: Callable[..., None] | None = None - self.instrument() - - # ---- BaseInstrumentor API ------------------------------------------------- - - def instrumentation_dependencies(self) -> Collection[str]: - return (_INSTRUMENTS,) - - def _instrument(self, **kwargs: Any) -> None: - tracer_name: str | None = kwargs.get("tracer_name") - tracer_version: str | None = kwargs.get("tracer_version") - - # Prefer the Agent 365 tracer; fall back to OpenTelemetry’s default if needed. - try: - tracer = get_tracer(tracer_name, tracer_version) - except Exception: - tracer = optel_trace.get_tracer(tracer_name, tracer_version) - - # Ensure tracer provider exists (ignore returned value; side-effect is enough). - try: - get_tracer_provider() - except Exception: - optel_trace.get_tracer_provider() - - self._tracer = CustomLangChainTracer( - tracer, - bool(kwargs.get("separate_trace_from_runtime_context")), - ) - - # Save and wrap BaseCallbackManager.__init__ to attach the processor once per instance. - self._original_cb_init = langchain_core.callbacks.BaseCallbackManager.__init__ - wrap_function_wrapper( - module="langchain_core.callbacks", - name="BaseCallbackManager.__init__", - wrapper=_BaseCallbackManagerInit(self._tracer), - ) - - def _uninstrument(self, **kwargs: Any) -> None: - # Restore original constructor if we wrapped it. - if self._original_cb_init is not None: - langchain_core.callbacks.BaseCallbackManager.__init__ = self._original_cb_init # type: ignore[assignment] - self._original_cb_init = None - self._tracer = None - - # ---- Helpers used by module-level functions ------------------------------- - - def get_span(self, run_id: UUID) -> Span | None: - """Return the span for a specific LangChain run_id, if available.""" - if not self._tracer: - logger.warning("Missing tracer; call InstrumentorForLangChain().instrument() first.") - return None - # TraceForLangChain is expected to expose get_span(run_id). - get_span_fn = getattr(self._tracer, "get_span", None) - return get_span_fn(run_id) if callable(get_span_fn) else None - - def get_ancestors(self, run_id: UUID) -> list[Span]: - """Return ancestor spans from the run’s parent up to the root (nearest first).""" - if not self._tracer: - logger.warning("Missing tracer; call InstrumentorForLangChain().instrument() first.") - return [] - - # Expect the processor to keep a run_map with parent linkage (string keys). - run_map = getattr(self._tracer, "run_map", {}) or {} - ancestors: list[Span] = [] - - run = run_map.get(str(run_id)) - if not run: - return ancestors - - ancestor_id = getattr(run, "parent_run_id", None) - while ancestor_id: - span = self.get_span(ancestor_id) - if span: - ancestors.append(span) - run = run_map.get(str(ancestor_id)) - ancestor_id = getattr(run, "parent_run_id", None) if run else None - - return ancestors - - -class _BaseCallbackManagerInit: - """Post-constructor hook that adds the TraceProcessor once (inheritable).""" - - __slots__ = ("_processor",) - - def __init__(self, processor: CustomLangChainTracer): - self._processor = processor - - def __call__( - self, - wrapped: Callable[..., None], - instance: BaseCallbackManager, - args: tuple[Any, ...], - kwargs: dict[str, Any], - ) -> None: - wrapped(*args, **kwargs) # run original __init__ - # Avoid duplicates: only add if a handler of the same type isn’t present. - if not any(isinstance(h, type(self._processor)) for h in instance.inheritable_handlers): - instance.add_handler(self._processor, inherit=True) - - -# ------------------------------ Convenience APIs ------------------------------ - - -def _current_parent_run_id() -> UUID | None: - """Best-effort: fetch current parent run_id from langchain runtime context.""" - config = langchain_core.runnables.config.var_child_runnable_config.get() - if not isinstance(config, dict): - return None - for v in config.values(): - if isinstance(v, langchain_core.callbacks.BaseCallbackManager): - if v.parent_run_id: - return v.parent_run_id - return None - - -def get_current_span() -> Span | None: - """Return the current context’s parent span, if any.""" - run_id = _current_parent_run_id() - if not run_id: - return None - return CustomLangChainInstrumentor().get_span(run_id) - - -def get_ancestor_spans() -> list[Span]: - """ - Return ancestor spans for the current context (immediate parent → root). - """ - run_id = _current_parent_run_id() - if not run_id: - return [] - return CustomLangChainInstrumentor().get_ancestors(run_id) diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py deleted file mode 100644 index 4e93e72a..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/microsoft_agents_a365/observability/extensions/langchain/utils.py +++ /dev/null @@ -1,777 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import json -from collections.abc import Iterable, Iterator, Mapping -from copy import deepcopy -from typing import Any - -from langchain_core.messages import BaseMessage -from langchain_core.tracers.schemas import Run -from microsoft_agents_a365.observability.core.constants import ( - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_RESPONSE_FINISH_REASONS_KEY, - GEN_AI_RESPONSE_ID_KEY, - GEN_AI_RESPONSE_MODEL_KEY, - GEN_AI_SYSTEM_INSTRUCTIONS_KEY, - GEN_AI_TOOL_ARGS_KEY, - GEN_AI_TOOL_CALL_ID_KEY, - GEN_AI_TOOL_CALL_RESULT_KEY, - GEN_AI_TOOL_DESCRIPTION_KEY, - GEN_AI_TOOL_NAME_KEY, - GEN_AI_TOOL_TYPE_KEY, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - INVOKE_AGENT_OPERATION_NAME, - SESSION_ID_KEY, -) -from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType -from microsoft_agents_a365.observability.core.utils import ( - get_first_value, - safe_json_dumps, - stop_on_exception, -) - -from .message_mapper import map_input_messages as _map_input -from .message_mapper import map_output_messages as _map_output - -IGNORED_EXCEPTION_PATTERNS = [ - r"^Command\(", - r"^ParentCommand\(", -] - -LANGCHAIN_SESSION_ID = "session_id" -LANGCHAIN_CONVERSATION_ID = "conversation_id" -LANGCHAIN_THREAD_ID = "thread_id" - - -@stop_on_exception -def prompts(inputs: Mapping[str, Any] | None) -> Iterator[tuple[str, list[str]]]: - """Yields prompts if present.""" - if not inputs: - return - if not isinstance(inputs, Mapping): - return - if prompts := inputs.get("prompts"): - yield GEN_AI_SYSTEM_INSTRUCTIONS_KEY, prompts - - -@stop_on_exception -def _extract_message_kwargs(message_data: Mapping[str, Any] | None) -> Iterator[[str, Any]]: - if not message_data: - return - if not isinstance(message_data, Mapping): - return - if kwargs := message_data.get("kwargs"): - if not isinstance(kwargs, Mapping): - return - if content := kwargs.get("content"): - # Just yield as-is (string or list) - yield "message.content", content - if tool_call_id := kwargs.get("tool_call_id"): - if isinstance(tool_call_id, str): - yield GEN_AI_TOOL_CALL_ID_KEY, tool_call_id - if name := kwargs.get("name"): - if isinstance(name, str): - yield "message.name", name - - -@stop_on_exception -def _extract_message_additional_kwargs( - message_data: Mapping[str, Any] | None, -) -> Iterator[tuple[str, Any]]: - if not message_data: - return - if not isinstance(message_data, Mapping): - return - if kwargs := message_data.get("kwargs"): - if not isinstance(kwargs, Mapping): - return - if additional_kwargs := kwargs.get("additional_kwargs"): - if not isinstance(additional_kwargs, Mapping): - return - if function_call := additional_kwargs.get("function_call"): - if not isinstance(function_call, Mapping): - return - if name := function_call.get("name"): - if isinstance(name, str): - yield GEN_AI_TOOL_NAME_KEY, name - if arguments := function_call.get("arguments"): - if isinstance(arguments, str): - yield GEN_AI_TOOL_ARGS_KEY, arguments - else: - yield GEN_AI_TOOL_ARGS_KEY, safe_json_dumps(arguments) - - -@stop_on_exception -def _get_tool_call(tool_call: Mapping[str, Any] | None) -> Iterator[tuple[str, Any]]: - if not tool_call: - return - if not isinstance(tool_call, Mapping): - return - - # id - id_ = tool_call.get("id") - if id_ is not None: - yield GEN_AI_TOOL_CALL_ID_KEY, id_ - - fn = tool_call.get("function") - name = None - arguments = None - - if isinstance(fn, Mapping): - name = fn.get("name") - arguments = fn.get("arguments") - else: - name = tool_call.get("name") - arguments = tool_call.get("args") - - # name - if name is not None and isinstance(name, str): - yield GEN_AI_TOOL_NAME_KEY, name - - # arguments -> always emit a JSON string - if arguments is not None: - if isinstance(arguments, str): - args_json = arguments - else: - args_json = safe_json_dumps(arguments) - yield GEN_AI_TOOL_ARGS_KEY, args_json - - -def _process_tool_calls(tool_calls: Any) -> str: - """Return all tool calls as a single compact string (JSON-joined), or '' if none.""" - if not tool_calls: - return "" - if not isinstance(tool_calls, Iterable): - return "" - - parts: list[str] = [] - for tool_call in tool_calls: - data = dict(_get_tool_call(tool_call)) - if data: - # Compact, stable representation - parts.append(safe_json_dumps(data, separators=(",", ":"), sort_keys=True)) - - return "; ".join(parts) - - -@stop_on_exception -def _extract_message_tool_calls( - message_data: Mapping[str, Any] | None, -) -> Iterator[tuple[str, str]]: - if not message_data: - return - if not isinstance(message_data, Mapping): - return - - # Collect tool_calls from multiple possible locations - all_tool_calls: list[str] = [] - - def collect(calls: Any) -> None: - if calls: - processed = _process_tool_calls(calls) - if processed: - if isinstance(processed, list): - all_tool_calls.extend(map(str, processed)) - else: - all_tool_calls.append(str(processed)) - - collect(message_data.get("tool_calls")) - - if kwargs := message_data.get("kwargs"): - if not isinstance(kwargs, Mapping): - return - collect(kwargs.get("tool_calls")) - - if additional_kwargs := kwargs.get("additional_kwargs"): - if not isinstance(additional_kwargs, Mapping): - return - collect(additional_kwargs.get("tool_calls")) - - if all_tool_calls: - # Return all as a single string (comma-separated) - yield "message.tool_calls", ", ".join(all_tool_calls) - - -@stop_on_exception -def _parse_message_data(message_data: Mapping[str, Any] | None) -> Iterator[tuple[str, Any]]: - """Parses message data to grab message role, content, etc.""" - yield from _extract_message_kwargs(message_data) - yield from _extract_message_additional_kwargs(message_data) - yield from _extract_message_tool_calls(message_data) - - -@stop_on_exception -def input_messages( - inputs: Mapping[str, Any] | None, -) -> Iterator[tuple[str, str]]: - """Yields input messages in A365 versioned format.""" - if not inputs: - return - - mapped = _map_input(inputs) - if mapped is not None: - yield GEN_AI_INPUT_MESSAGES_KEY, mapped - - -@stop_on_exception -def metadata(run: Run) -> Iterator[tuple[str, str]]: - """ - Takes the LangChain chain metadata and adds it to the trace - """ - if not run.extra or not (metadata := run.extra.get("metadata")): - return - if not isinstance(metadata, Mapping): - return - if session_id := ( - metadata.get(LANGCHAIN_SESSION_ID) - or metadata.get(LANGCHAIN_CONVERSATION_ID) - or metadata.get(LANGCHAIN_THREAD_ID) - ): - yield SESSION_ID_KEY, session_id - - -@stop_on_exception -def output_messages( - outputs: Mapping[str, Any] | None, -) -> Iterator[tuple[str, str]]: - """Yields output messages in A365 versioned format.""" - if not outputs: - return - if not isinstance(outputs, Mapping): - return - # Preserve response ID extraction - output_type = outputs.get("type") - if output_type and output_type.lower() == "llmresult": - llm_output = outputs.get("llm_output") - if llm_output and hasattr(llm_output, "get"): - response_id = llm_output.get("id") - if response_id: - yield GEN_AI_RESPONSE_ID_KEY, response_id - - mapped = _map_output(outputs) - if mapped is not None: - yield GEN_AI_OUTPUT_MESSAGES_KEY, mapped - - -@stop_on_exception -def invocation_parameters(run: Run) -> Iterator[tuple[str, str]]: - """Yields invocation parameters if present.""" - if run.run_type.lower() != "llm": - return - if not (extra := run.extra): - return - if not isinstance(extra, Mapping): - return - if invocation_parameters := extra.get("invocation_params"): - if not isinstance(invocation_parameters, Mapping): - return - tools = invocation_parameters.get("tools", []) - for idx, tool in enumerate(tools): - yield f"{GEN_AI_TOOL_ARGS_KEY}.{idx}", safe_json_dumps(tool) - - -@stop_on_exception -def llm_provider(extra: Mapping[str, Any] | None) -> Iterator[tuple[str, str]]: - if not extra: - return - if (meta := extra.get("metadata")) and (ls_provider := meta.get("ls_provider")): - ls_provider_lower = ls_provider.lower() - yield GEN_AI_PROVIDER_NAME_KEY, ls_provider_lower - - -@stop_on_exception -def model_name( - outputs: Mapping[str, Any] | None, - extra: Mapping[str, Any] | None, -) -> Iterator[tuple[str, str]]: - """Yields model name if present.""" - if ( - outputs - and hasattr(outputs, "get") - and (llm_output := outputs.get("llm_output")) - and hasattr(llm_output, "get") - ): - for key in "model_name", "model": - if name := str(llm_output.get(key) or "").strip(): - yield GEN_AI_RESPONSE_MODEL_KEY, name - break - if not extra: - return - if not isinstance(extra, Mapping): - return - if ( - (metadata := extra.get("metadata")) - and hasattr(metadata, "get") - and (ls_model_name := str(metadata.get("ls_model_name") or "").strip()) - ): - yield GEN_AI_REQUEST_MODEL_KEY, ls_model_name - return - if not (invocation_params := extra.get("invocation_params")): - return - for key in ["model_name", "model"]: - if name := invocation_params.get(key): - yield GEN_AI_REQUEST_MODEL_KEY, name - return - - -@stop_on_exception -def token_counts(outputs: Mapping[str, Any] | None) -> Iterator[tuple[str, int]]: - """Yields token count information if present.""" - token_usage = parse_token_usage_for_non_streaming_outputs(outputs) - message_usage = _message_usage_metadata(outputs) - if not token_usage and not message_usage: - return - - for attribute_name, keys in [ - ( - GEN_AI_USAGE_INPUT_TOKENS_KEY, - ( - "prompt_tokens", - "input_tokens", # Anthropic-specific key - "prompt_token_count", # Gemini-specific key - https://ai.google.dev/gemini-api/docs/tokens?lang=python - ), - ), - ( - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - ( - "completion_tokens", - "output_tokens", # Anthropic-specific key - "candidates_token_count", # Gemini-specific key - ), - ), - ]: - if ( - token_usage is not None - and (token_count := get_first_value(token_usage, keys)) is not None - ): - yield attribute_name, token_count - - if token_usage is not None: - # OpenAI - for attribute_name, details_key, keys in [ - ( - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - "prompt_tokens_details", - ("cached_tokens",), - ), - ( - GEN_AI_RESPONSE_FINISH_REASONS_KEY, - "completion_tokens_details", - ("reasoning_tokens",), - ), - ]: - if (details := token_usage.get(details_key)) is not None: - if (token_count := get_first_value(details, keys)) is not None: - yield attribute_name, token_count - - # Anthropic's raw (non-normalized) usage payload places cache counts - # as flat fields instead of nesting them under `input_token_details`, - # e.g. llm_output["usage"]["cache_read_input_tokens"]. - for attribute_name, keys in [ - (GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, ("cache_read_input_tokens",)), - (GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, ("cache_creation_input_tokens",)), - ]: - if (token_count := get_first_value(token_usage, keys)) is not None: - yield attribute_name, token_count - - # maps langchain_core.messages.ai.UsageMetadata object. For some providers - # (e.g. OpenAI with stream_usage) this ends up merged into llm_output's - # token usage, but others (e.g. ChatAnthropic) only normalize cache - # details onto the generated AIMessage itself (message.usage_metadata), - # so prefer the message's own usage_metadata when present and fall back - # to the llm_output-based token_usage otherwise. - usage_metadata_sources = [ - source for source in (message_usage, token_usage) if source is not None - ] - for attribute_name, details_key_or_none, keys in [ - (GEN_AI_USAGE_INPUT_TOKENS_KEY, None, ("input_tokens",)), - (GEN_AI_USAGE_OUTPUT_TOKENS_KEY, None, ("output_tokens",)), - ( - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - "input_token_details", - ("cache_read", "cached_tokens"), - ), - ( - GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - "input_token_details", - ("cache_write", "cache_creation"), - ), - # Note: `output_token_details.reasoning` is intentionally not mapped - # here - it is a reasoning *token count*, not a finish reason, and - # there is no dedicated reasoning-tokens attribute defined yet. - ]: - for source in usage_metadata_sources: - details = source.get(details_key_or_none) if details_key_or_none else source - if details is not None and (token_count := get_first_value(details, keys)) is not None: - yield attribute_name, token_count - break - - -def parse_token_usage_for_non_streaming_outputs( - outputs: Mapping[str, Any] | None, -) -> Any: - """ - Parses output to get token usage information for non-streaming LLMs, i.e., - when `stream_usage` is set to false. - """ - if ( - outputs - and hasattr(outputs, "get") - and (llm_output := outputs.get("llm_output")) - and hasattr(llm_output, "get") - and ( - token_usage := get_first_value( - llm_output, - ( - "token_usage", - "usage", # Anthropic-specific key - ), - ) - ) - ): - return token_usage - return None - - -def _merge_usage_metadata(aggregate: dict[str, Any], usage_metadata: Mapping[str, Any]) -> None: - """ - Merges `usage_metadata` into `aggregate` in place, summing numeric token - counts (including nested detail mappings like `input_token_details`) so - that usage from multiple batch entries is aggregated rather than - overwritten. - """ - for key, value in usage_metadata.items(): - if isinstance(value, Mapping): - nested = aggregate.get(key) - if not isinstance(nested, dict): - nested = {} - aggregate[key] = nested - _merge_usage_metadata(nested, value) - elif isinstance(value, (int, float)) and not isinstance(value, bool): - aggregate[key] = aggregate.get(key, 0) + value - elif key not in aggregate: - aggregate[key] = value - - -def _message_usage_metadata(outputs: Mapping[str, Any] | None) -> Mapping[str, Any] | None: - """ - Extracts the normalized `usage_metadata` (LangChain's `UsageMetadata`) - from the generated messages in `outputs["generations"]`, if present. - - LangChain attaches its normalized token/cache usage directly to the - generated `AIMessage` (`message.usage_metadata`) rather than always - surfacing it on `llm_output` - for example, ChatAnthropic's raw - `llm_output["usage"]` only has flat, non-normalized fields, while the - normalized cache details live on the message. Handles both an actual - `BaseMessage` instance (attribute access) and LangChain's `dumpd`-style - serialized dict form (`{"type": "constructor", "kwargs": {...}}`). - - `outputs["generations"]` is a list of batches, each containing one or - more alternative completions for the same input (e.g. when `n > 1`). - Alternative completions for the same input share the same usage - accounting, so only the first completion per batch entry is used; usage - is then summed across batch entries so multi-input batch calls aren't - under-reported. - """ - if not outputs or not isinstance(outputs, Mapping): - return None - generations = outputs.get("generations") - if not generations or not isinstance(generations, Iterable): - return None - - aggregate: dict[str, Any] | None = None - for batch_entry in generations: - if not batch_entry or not isinstance(batch_entry, Iterable): - continue - - usage_metadata = None - for generation in batch_entry: - message = ( - generation.get("message") - if isinstance(generation, Mapping) - else getattr(generation, "message", None) - ) - if message is None: - continue - - if isinstance(message, BaseMessage): - usage_metadata = getattr(message, "usage_metadata", None) - elif isinstance(message, Mapping): - kwargs = message.get("kwargs") - usage_metadata = ( - kwargs.get("usage_metadata") if isinstance(kwargs, Mapping) else None - ) - if usage_metadata is None: - usage_metadata = message.get("usage_metadata") - else: - usage_metadata = None - - if usage_metadata: - # Only the first completion with usage data in this batch - # entry is used - alternative completions (n > 1) for the - # same input would otherwise be double-counted. - break - - if not usage_metadata: - continue - - if aggregate is None: - aggregate = {} - _merge_usage_metadata(aggregate, usage_metadata) - - return aggregate - - -@stop_on_exception -def function_calls(outputs: Mapping[str, Any] | None) -> Iterator[tuple[str, str]]: - """ - Extract a single OpenAI-style function call from model outputs and emit - GenAI tool attributes as (key, value) pairs. Arguments/result are JSON strings. - - """ - if not outputs: - return - if not isinstance(outputs, Mapping): - return - - try: - # Typical OpenAI LangChain shape: - # outputs["generations"][0][0]["message"]["kwargs"]["additional_kwargs"]["function_call"] - fc = deepcopy( - outputs["generations"][0][0]["message"]["kwargs"]["additional_kwargs"]["function_call"] - ) - except Exception: - return - - if not isinstance(fc, dict): - return - - # Tool type (explicit) - yield GEN_AI_OPERATION_NAME_KEY, EXECUTE_TOOL_OPERATION_NAME - yield GEN_AI_TOOL_TYPE_KEY, "function" - - name = fc.get("name") - if isinstance(name, str): - yield GEN_AI_TOOL_NAME_KEY, name - - desc = fc.get("description") - if isinstance(desc, str): - yield GEN_AI_TOOL_DESCRIPTION_KEY, desc - - call_id = fc.get("id") - if isinstance(call_id, str): - yield GEN_AI_TOOL_CALL_ID_KEY, call_id - - args = fc.get("arguments") - if args is not None: - if isinstance(args, str): - # If it's a JSON string, try to parse then re-dump for normalization - try: - args_json = safe_json_dumps(json.loads(args)) - except Exception: - # Not valid JSON; store raw string - args_json = safe_json_dumps(args) - else: - args_json = safe_json_dumps(args) - yield GEN_AI_TOOL_ARGS_KEY, args_json - - result = fc.get("result") - if result is not None: - yield GEN_AI_TOOL_CALL_RESULT_KEY, safe_json_dumps(result) - - -@stop_on_exception -def tools(run: Run) -> Iterator[tuple[str, str]]: - """Yields tool attributes if present.""" - if run.run_type.lower() != "tool": - return - if not (serialized := run.serialized): - return - if not isinstance(serialized, Mapping): - return - yield GEN_AI_TOOL_TYPE_KEY, "extension" - if name := serialized.get("name"): - yield GEN_AI_TOOL_NAME_KEY, name - if description := serialized.get("description"): - yield GEN_AI_TOOL_DESCRIPTION_KEY, description - - # Extract tool call ID from run.extra (LangGraph stores it there) - if run.extra and hasattr(run.extra, "get"): - if tool_call_id := run.extra.get("tool_call_id"): - yield GEN_AI_TOOL_CALL_ID_KEY, tool_call_id - - # Extract tool arguments from inputs - if run.inputs and hasattr(run.inputs, "get"): - # LangGraph wraps args in 'input' key as a string - if input_val := run.inputs.get("input"): - if isinstance(input_val, str): - yield GEN_AI_TOOL_ARGS_KEY, input_val - else: - yield GEN_AI_TOOL_ARGS_KEY, safe_json_dumps(input_val) - - # Extract tool result from outputs - if run.outputs and hasattr(run.outputs, "get"): - if result := run.outputs.get("output"): - # Handle ToolMessage or BaseMessage objects - if isinstance(result, BaseMessage): - result_content = result.content if hasattr(result, "content") else str(result) - elif hasattr(result, "content"): - result_content = result.content - elif isinstance(result, str): - result_content = result - else: - result_content = safe_json_dumps(result) - yield GEN_AI_TOOL_CALL_RESULT_KEY, result_content - - -def add_operation_type(run: Run) -> Iterator[tuple[str, str]]: - """Yields operation type based on run type.""" - run_type = run.run_type.lower() - if run_type == "llm": - yield GEN_AI_OPERATION_NAME_KEY, InferenceOperationType.CHAT.value.lower() - elif run_type == "chat_model": - yield GEN_AI_OPERATION_NAME_KEY, InferenceOperationType.CHAT.value.lower() - elif run_type == "tool": - yield GEN_AI_OPERATION_NAME_KEY, EXECUTE_TOOL_OPERATION_NAME - elif run_type == "chain" and run.name.startswith(INVOKE_AGENT_OPERATION_NAME): - yield GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME - - -def _extract_content_from_message(message: Any) -> str | None: - """Extract content from a LangChain message object or dict.""" - if message is None: - return None - - # Handle BaseMessage objects - if isinstance(message, BaseMessage): - return message.content if hasattr(message, "content") else None - - # Handle dict-like messages - if hasattr(message, "get"): - # Direct content field - if content := message.get("content"): - return content - # Nested in kwargs - if kwargs := message.get("kwargs"): - if hasattr(kwargs, "get") and (content := kwargs.get("content")): - return content - - return None - - -def _get_message_role(message: Any) -> str | None: - """Extract role from a LangChain message object or dict.""" - if message is None: - return None - - # Handle BaseMessage objects - if isinstance(message, BaseMessage): - return message.type if hasattr(message, "type") else None - - # Handle dict-like messages - if hasattr(message, "get"): - # Check various role indicators - if role := message.get("role"): - return role - if msg_type := message.get("type"): - return msg_type - # Check id field for type info (e.g., "HumanMessage", "AIMessage") - if id_field := message.get("id"): - if isinstance(id_field, list) and len(id_field) > 0: - type_name = id_field[-1] - if "Human" in type_name: - return "human" - elif "AI" in type_name or "Assistant" in type_name: - return "ai" - elif "System" in type_name: - return "system" - - return None - - -@stop_on_exception -def invoke_agent_input_message( - inputs: Mapping[str, Any] | None, -) -> Iterator[tuple[str, str]]: - """ - Extract the user input message for invoke_agent spans (LangGraph root). - We want to find the first user/human message content. - """ - if not inputs: - return - - if not isinstance(inputs, Mapping): - return - - messages = inputs.get("messages") - if not messages: - return - - # Handle nested list structure: [[msg1, msg2, ...]] - if isinstance(messages, list) and len(messages) > 0: - first_item = messages[0] - # If first item is also a list, unwrap it - if isinstance(first_item, list): - messages = first_item - - # Find the first user/human message - if isinstance(messages, list): - for message in messages: - role = _get_message_role(message) - if role and role.lower() in ("human", "user"): - content = _extract_content_from_message(message) - if content: - yield GEN_AI_INPUT_MESSAGES_KEY, content - return - - # If no human message found, just get first message content - if len(messages) > 0: - content = _extract_content_from_message(messages[0]) - if content: - yield GEN_AI_INPUT_MESSAGES_KEY, content - - -@stop_on_exception -def invoke_agent_output_message( - outputs: Mapping[str, Any] | None, -) -> Iterator[tuple[str, str]]: - """ - Extract the final output message for invoke_agent spans (LangGraph root). - We want the last AI/assistant message content. - """ - if not outputs: - return - - if not isinstance(outputs, Mapping): - return - - messages = outputs.get("messages") - if not messages: - return - - # Handle nested list structure if present - if isinstance(messages, list) and len(messages) > 0: - first_item = messages[0] - if isinstance(first_item, list): - messages = first_item - - # Find the last AI/assistant message with content (not tool calls) - if isinstance(messages, list): - # Iterate in reverse to find the last AI message - for message in reversed(messages): - role = _get_message_role(message) - if role and role.lower() in ("ai", "assistant"): - content = _extract_content_from_message(message) - # Make sure it has actual content, not just tool calls - if content and isinstance(content, str) and content.strip(): - yield GEN_AI_OUTPUT_MESSAGES_KEY, content - return diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml deleted file mode 100644 index 7677b032..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml +++ /dev/null @@ -1,85 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] -build-backend = "build_backend" - -[project] -name = "microsoft-agents-a365-observability-extensions-langchain" -dynamic = ["version"] -authors = [ - { name = "Microsoft", email = "support@microsoft.com" }, -] -description = "LangChain observability and tracing extensions for Microsoft Agent 365" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", -] -license = {text = "MIT"} -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "langchain", "agents", "ai"] -dependencies = [ - "microsoft-agents-a365-observability-core", - "langchain-core", - "opentelemetry-api", - "opentelemetry-sdk", - "opentelemetry-instrumentation", - "wrapt", - # Publish security floors for LangChain's transitive dependencies. - "langsmith", - "anyio", -] - -[project.urls] -Homepage = "https://github.com/microsoft/Agent365-python" -Repository = "https://github.com/microsoft/Agent365-python" -Issues = "https://github.com/microsoft/Agent365-python/issues" -Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-extensions-langchain" - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "ruff", - "black", - "mypy", -] -test = [ - "pytest", - "pytest-asyncio", -] - -[tool.setuptools.packages.find] -where = ["."] -namespaces = true - -[tool.setuptools] -license-files = ["../../LICENSE"] -include-package-data = true - -[tool.setuptools.package-data] -"*" = ["../../LICENSE"] - -[tool.black] -line-length = 100 -target-version = ['py311'] - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint.flake8-copyright] -notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." -min-file-size = 1 - -[tool.mypy] -python_version = "3.11" -strict = true -warn_return_any = true -warn_unused_configs = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py b/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py deleted file mode 100644 index 8fbb2a49..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import sys -from pathlib import Path -from os import environ -from setuptools import setup - -# Get version from environment variable set by CI/CD -package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") - -# Add versioning helper to path -helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" -sys.path.insert(0, str(helper_path)) - -from setup_utils import get_dynamic_dependencies # noqa: E402 - -# Use exact version matching for internal dependencies: -# - Internal packages get: == current_version (e.g., == 1.2.3) -# - Ensures all SDK packages must be at the same version -# - Prevents incompatibility issues from version mismatches -setup( - version=package_version, - install_requires=get_dynamic_dependencies( - use_exact_match=True, - ), -) diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/README.md b/libraries/microsoft-agents-a365-observability-extensions-openai/README.md deleted file mode 100644 index 117b118d..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# microsoft-agents-a365-observability-extensions-openai - -[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-observability-extensions-openai?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-openai) -[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-observability-extensions-openai?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-openai) - -Observability extensions for OpenAI Agents SDK. This package provides OpenTelemetry tracing integration for OpenAI Agents-based applications with automatic instrumentation for agent workflows and tool invocations. - -## Installation - -```bash -pip install microsoft-agents-a365-observability-extensions-openai -``` - -## Usage - -For usage examples and detailed documentation, see the [Observability documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=python) on Microsoft Learn. - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details. diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/docs/design.md b/libraries/microsoft-agents-a365-observability-extensions-openai/docs/design.md deleted file mode 100644 index b2d42925..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/docs/design.md +++ /dev/null @@ -1,61 +0,0 @@ -# Observability Extensions - OpenAI - Design Document - -This document describes the architecture and design of the `microsoft-agents-a365-observability-extensions-openai` package. - -## Overview - -This extension integrates the Agent365 observability infrastructure with the OpenAI Agents SDK, enabling automatic tracing of OpenAI API calls. - -## Key Components - -### OpenAIAgentsTraceInstrumentor - -The main instrumentor class that wraps OpenAI agent operations. - -```python -from microsoft_agents_a365.observability.extensions.openai import OpenAIAgentsTraceInstrumentor - -# Initialize instrumentor -instrumentor = OpenAIAgentsTraceInstrumentor() - -# Instrument OpenAI calls -instrumentor.instrument() -``` - -### TraceProcessor - -Custom processor that intercepts OpenAI SDK trace events and converts them to Agent365 spans. - -### Integration Flow - -``` -OpenAI Agents SDK - │ - ▼ -TraceInstrumentor (intercepts calls) - │ - ▼ -TraceProcessor (creates Agent365 spans) - │ - ▼ -InferenceScope / ExecuteToolScope - │ - ▼ -Agent365 Backend -``` - -## File Structure - -``` -microsoft_agents_a365/observability/extensions/openai/ -├── __init__.py # Public API -├── trace_instrumentor.py # OpenAIAgentsTraceInstrumentor -├── trace_processor.py # Trace event processor -├── constants.py # OpenAI-specific constants -└── utils.py # Utility functions -``` - -## Dependencies - -- `openai-agents` - OpenAI Agents SDK -- `microsoft-agents-a365-observability-core` - Core observability diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py deleted file mode 100644 index 2b548f52..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Wraps the OpenAI Agents SDK tracer to integrate with the Microsoft Agent 365 Telemetry Solution. -""" - -from .trace_instrumentor import OpenAIAgentsTraceInstrumentor - -__all__ = ["OpenAIAgentsTraceInstrumentor"] diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py deleted file mode 100644 index 2cd5b415..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/constants.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Span Attribute Types -from microsoft_agents_a365.observability.core.constants import ( - EXECUTE_TOOL_OPERATION_NAME, - INVOKE_AGENT_OPERATION_NAME, -) -from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType - -GEN_AI_SPAN_KIND_AGENT_KEY = INVOKE_AGENT_OPERATION_NAME -GEN_AI_SPAN_KIND_TOOL_KEY = EXECUTE_TOOL_OPERATION_NAME -GEN_AI_SPAN_KIND_CHAIN_KEY = "chain" -GEN_AI_SPAN_KIND_LLM_KEY = InferenceOperationType.CHAT.value.lower() -GEN_AI_SPAN_KIND_RETRIEVER_KEY = "retriever" -GEN_AI_SPAN_KIND_EMBEDDING_KEY = "embedding" -GEN_AI_SPAN_KIND_RERANKER_KEY = "reranker" -GEN_AI_SPAN_KIND_GUARDRAIL_KEY = "guardrail" -GEN_AI_SPAN_KIND_EVALUATOR_KEY = "evaluator" -GEN_AI_SPAN_KIND_UNKNOWN_KEY = "unknown" - -# PREFIXES -GEN_AI_MESSAGE_ROLE = "message_role" -GEN_AI_MESSAGE_CONTENT = "message_content" -GEN_AI_MESSAGE_CONTENTS = "message_contents" -GEN_AI_MESSAGE_CONTENT_TYPE = "content_type" -GEN_AI_MESSAGE_TOOL_CALLS = "message_tool_calls" -GEN_AI_MESSAGE_TOOL_CALL_ID = "message_tool_id" -GEN_AI_MESSAGE_TOOL_CALL_NAME = "message_tool_name" -GEN_AI_TOOL_JSON_SCHEMA = "tool_json_schema" -GEN_AI_LLM_TOKEN_COUNT_TOTAL = "llm_token_count_total" -GEN_AI_LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHED_READ = "llm_token_count_prompt_details_cached_read" -GEN_AI_LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING = "llm_token_count_completion_details_reasoning" -GEN_AI_GRAPH_NODE_ID = "graph_node_id" -GEN_AI_GRAPH_NODE_PARENT_ID = "graph_node_parent_id" diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/message_mapper.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/message_mapper.py deleted file mode 100644 index 515fdadc..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/message_mapper.py +++ /dev/null @@ -1,381 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Maps OpenAI span tag messages to A365 versioned message format. - -Handles three input shapes produced by the OpenAI trace processor: - -1. **Chat-completions format** (from ``GenerationSpanData``): - ``[{"role":"system","content":"..."}, ...]`` -2. **Response API format** (from ``ResponseSpanData``): - - Input: ``[{"type":"message","role":"user","content":"..."}, ...]`` - - Output: ``{"id":"...","model":"...","output":[...], ...}`` (full Response JSON) -3. **Plain string** (from ``AgentSpanData``): - A bare user/assistant message captured from child generation spans. -""" - -from __future__ import annotations - -import json -import logging -from collections.abc import Mapping -from typing import Any - -from microsoft_agents_a365.observability.core.message_utils import serialize_messages -from microsoft_agents_a365.observability.core.models.messages import ( - ChatMessage, - InputMessages, - MessagePart, - MessageRole, - OutputMessage, - OutputMessages, - TextPart, - ToolCallRequestPart, - ToolCallResponsePart, -) - -logger = logging.getLogger(__name__) - -_ROLE_MAP: dict[str, MessageRole] = { - "system": MessageRole.SYSTEM, - "user": MessageRole.USER, - "assistant": MessageRole.ASSISTANT, - "tool": MessageRole.TOOL, -} - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def map_input_messages(messages_json: str) -> str | None: - """Map a ``gen_ai.input.messages`` tag value to a serialized A365 JSON string. - - Args: - messages_json: The raw JSON string from the span attribute. - - Returns: - Serialized ``InputMessages`` JSON string, or ``None`` if the - input is empty or cannot be parsed. - """ - if not messages_json: - return None - - # Plain string (AgentSpanData captures bare user text) - try: - raw = json.loads(messages_json) - except (json.JSONDecodeError, TypeError): - return _wrap_plain_input(messages_json) - - if isinstance(raw, list): - return _map_input_list(raw) - - # Unexpected shape - return _wrap_plain_input(messages_json) - - -def map_output_messages(messages_json: str) -> str | None: - """Map a ``gen_ai.output.messages`` tag value to a serialized A365 JSON string. - - Args: - messages_json: The raw JSON string from the span attribute. - - Returns: - Serialized ``OutputMessages`` JSON string, or ``None`` if the - input is empty or cannot be parsed. - """ - if not messages_json: - return None - - try: - raw = json.loads(messages_json) - except (json.JSONDecodeError, TypeError): - return _wrap_plain_output(messages_json) - - if isinstance(raw, list): - return _map_output_list(raw) - - if isinstance(raw, dict): - # Full Response JSON from ResponseSpanData (model_dump_json) - return _map_response_output(raw) - - return _wrap_plain_output(messages_json) - - -# --------------------------------------------------------------------------- -# Input mapping -# --------------------------------------------------------------------------- - - -def _map_input_list(items: list[Any]) -> str | None: - """Map a list of input items (chat completions or ResponseInputItemParam).""" - chat_messages: list[ChatMessage] = [] - - for item in items: - if not isinstance(item, dict): - continue - - item_type = item.get("type") - - if item_type == "function_call": - # ResponseInputItemParam: function_call → assistant tool call request - name = item.get("name", "") - if name: - parts: list[MessagePart] = [ - ToolCallRequestPart( - name=name, - id=item.get("call_id"), - arguments=item.get("arguments"), - ) - ] - chat_messages.append(ChatMessage(role=MessageRole.ASSISTANT, parts=parts)) - - elif item_type == "function_call_output": - # ResponseInputItemParam: function_call_output → tool response - parts = [ - ToolCallResponsePart( - id=item.get("call_id"), - response=item.get("output"), - ) - ] - chat_messages.append(ChatMessage(role=MessageRole.TOOL, parts=parts)) - - elif item_type == "custom_tool_call": - name = item.get("name", "") - if name: - input_data = item.get("input") - args = json.dumps({"input": input_data}) if input_data is not None else None - parts = [ToolCallRequestPart(name=name, id=item.get("call_id"), arguments=args)] - chat_messages.append(ChatMessage(role=MessageRole.ASSISTANT, parts=parts)) - - elif item_type == "custom_tool_call_output": - parts = [ - ToolCallResponsePart( - id=item.get("call_id"), - response=item.get("output"), - ) - ] - chat_messages.append(ChatMessage(role=MessageRole.TOOL, parts=parts)) - - elif item_type == "message" or "role" in item: - # Standard message (ResponseInputItemParam or chat completions) - mapped = _map_chat_completions_message(item) - if mapped is not None: - chat_messages.append(mapped) - - else: - # Unknown type, try as generic message - mapped = _map_chat_completions_message(item) - if mapped is not None: - chat_messages.append(mapped) - - if not chat_messages: - return None - return serialize_messages(InputMessages(messages=chat_messages)) - - -def _map_chat_completions_message(msg: dict[str, Any]) -> ChatMessage | None: - """Map a single chat-completions-style message dict.""" - role_str = msg.get("role", "") - role = _ROLE_MAP.get(str(role_str).lower(), MessageRole.USER) - parts: list[MessagePart] = [] - - # Tool response message - if role == MessageRole.TOOL: - content = msg.get("content", "") - tool_call_id = msg.get("tool_call_id") - response = str(content) if content else "" - if response or tool_call_id: - parts.append(ToolCallResponsePart(id=tool_call_id, response=response)) - return ChatMessage(role=role, parts=parts) if parts else None - - # Text content (string or list) - content = msg.get("content") - if isinstance(content, str) and content.strip(): - parts.append(TextPart(content=content)) - elif isinstance(content, list): - for item in content: - if isinstance(item, dict): - if item.get("type") in ("input_text", "text"): - text = item.get("text", "") - if text: - parts.append(TextPart(content=text)) - elif item.get("type") == "output_text": - text = item.get("text", "") - if text: - parts.append(TextPart(content=text)) - - # Tool calls - tool_calls = msg.get("tool_calls") - if isinstance(tool_calls, list): - for tc in tool_calls: - if not isinstance(tc, dict): - continue - func = tc.get("function", {}) - if isinstance(func, dict): - name = func.get("name") - if name: - parts.append( - ToolCallRequestPart( - name=name, - id=tc.get("id"), - arguments=func.get("arguments"), - ) - ) - - if not parts: - return None - return ChatMessage(role=role, parts=parts, name=msg.get("name")) - - -# --------------------------------------------------------------------------- -# Output mapping -# --------------------------------------------------------------------------- - - -def _map_output_list(items: list[Any]) -> str | None: - """Map a list of chat-completions-style output messages.""" - output_messages: list[OutputMessage] = [] - - for item in items: - if not isinstance(item, dict): - continue - role_str = item.get("role", "assistant") - role = _ROLE_MAP.get(str(role_str).lower(), MessageRole.ASSISTANT) - parts: list[MessagePart] = [] - - # Tool response - if role == MessageRole.TOOL: - content = item.get("content", "") - tool_call_id = item.get("tool_call_id") - response = str(content) if content else "" - if response or tool_call_id: - parts.append(ToolCallResponsePart(id=tool_call_id, response=response)) - else: - # Text content - content = item.get("content") - if isinstance(content, str) and content.strip(): - parts.append(TextPart(content=content)) - elif isinstance(content, list): - for c in content: - if isinstance(c, dict): - text = c.get("text", "") - if text: - parts.append(TextPart(content=text)) - - # Tool calls - tool_calls = item.get("tool_calls") - if isinstance(tool_calls, list): - for tc in tool_calls: - if not isinstance(tc, dict): - continue - func = tc.get("function", {}) - if isinstance(func, dict): - name = func.get("name") - if name: - parts.append( - ToolCallRequestPart( - name=name, - id=tc.get("id"), - arguments=func.get("arguments"), - ) - ) - - finish_reason = item.get("finish_reason") - if parts: - output_messages.append( - OutputMessage(role=role, parts=parts, finish_reason=finish_reason) - ) - - if not output_messages: - return None - return serialize_messages(OutputMessages(messages=output_messages)) - - -def _map_response_output(response: dict[str, Any]) -> str | None: - """Map a full OpenAI Response JSON to A365 OutputMessages. - - The Response object has ``output: [...]`` containing items with - ``type`` of ``message`` or ``function_call``. - """ - output_items = response.get("output") - if not isinstance(output_items, list): - return None - - output_messages: list[OutputMessage] = [] - - for item in output_items: - if not isinstance(item, Mapping): - continue - item_type = item.get("type") - - if item_type == "message": - parts: list[MessagePart] = [] - role_str = item.get("role", "assistant") - role = _ROLE_MAP.get(str(role_str).lower(), MessageRole.ASSISTANT) - - for content_item in item.get("content", []): - if isinstance(content_item, Mapping): - content_type = content_item.get("type") - if content_type == "output_text": - text = content_item.get("text", "") - if text: - parts.append(TextPart(content=text)) - elif content_type == "refusal": - text = content_item.get("refusal", "") - if text: - parts.append(TextPart(content=text)) - - if parts: - finish_reason = item.get("status") - output_messages.append( - OutputMessage(role=role, parts=parts, finish_reason=finish_reason) - ) - - elif item_type == "function_call": - name = item.get("name", "") - if name: - parts = [ - ToolCallRequestPart( - name=name, - id=item.get("call_id"), - arguments=item.get("arguments"), - ) - ] - output_messages.append( - OutputMessage( - role=MessageRole.ASSISTANT, - parts=parts, - finish_reason="tool_call", - ) - ) - - if not output_messages: - return None - return serialize_messages(OutputMessages(messages=output_messages)) - - -# --------------------------------------------------------------------------- -# Plain-string wrappers -# --------------------------------------------------------------------------- - - -def _wrap_plain_input(text: str) -> str | None: - """Wrap a plain text string as a versioned InputMessages.""" - if not text or not text.strip(): - return None - return serialize_messages( - InputMessages(messages=[ChatMessage(role=MessageRole.USER, parts=[TextPart(content=text)])]) - ) - - -def _wrap_plain_output(text: str) -> str | None: - """Wrap a plain text string as a versioned OutputMessages.""" - if not text or not text.strip(): - return None - return serialize_messages( - OutputMessages( - messages=[OutputMessage(role=MessageRole.ASSISTANT, parts=[TextPart(content=text)])] - ) - ) diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py deleted file mode 100644 index 9e28f8d6..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Wrapper for OpenAI Agents SDK - -import logging -from collections.abc import Collection -from typing import Any, cast - -import opentelemetry.trace as optel_trace -from agents import set_trace_processors -from microsoft_agents_a365.observability.core import get_tracer, get_tracer_provider, is_configured -from opentelemetry.instrumentation.instrumentor import BaseInstrumentor -from opentelemetry.trace import Tracer - -from .trace_processor import OpenAIAgentsTraceProcessor - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -_instruments = ("openai-agents >= 0.2.6",) - - -class OpenAIAgentsTraceInstrumentor(BaseInstrumentor): - """Custom Trace Processor for OpenAI Agents SDK using Microsoft Agent 365. - - Forwards OpenAI Agents SDK traces and spans to Microsoft Agent 365's tracing scopes. - """ - - def __init__(self): - """Initialize the OpenAIAgentsTraceInstrumentor. - Raises: RuntimeError: If Microsoft Agent 365 is not configured. - """ - # Verify if Microsoft Agent 365 is configured - Agent365_status = is_configured() - if not Agent365_status: - raise RuntimeError( - "Microsoft Agent 365 is not configured yet. Please configure Microsoft Agent 365 before initializing this instrumentor." - ) - super().__init__() - - def instrumentation_dependencies(self) -> Collection[str]: - return _instruments - - def _instrument(self, **kwargs: Any) -> None: - """Instruments the OpenAI Agents SDK with Microsoft Agent 365 tracing.""" - tracer_name = kwargs["tracer_name"] if kwargs.get("tracer_name") else None - tracer_version = kwargs["tracer_version"] if kwargs.get("tracer_version") else None - - # Get the configured Microsoft Agent 365 Tracer - try: - tracer = get_tracer(tracer_name, tracer_version) - except Exception: - # fallback - tracer = optel_trace.get_tracer(tracer_name, tracer_version) - - # Get the configured Microsoft Agent 365 Tracer Provider instance - try: - get_tracer_provider() - except Exception: - # fallback - optel_trace.get_tracer_provider() - - agent365_tracer = cast(Tracer, tracer) - - set_trace_processors([OpenAIAgentsTraceProcessor(agent365_tracer)]) - - def _uninstrument(self, **kwargs: Any) -> None: - set_trace_processors([]) diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py deleted file mode 100644 index eac4b67d..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py +++ /dev/null @@ -1,301 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Processor for OpenAI Agents SDK - -from __future__ import annotations - -import logging -from collections import OrderedDict -from datetime import datetime -from typing import TYPE_CHECKING, Any, assert_never - -from agents import MCPListToolsSpanData -from agents.tracing import Span, Trace, TracingProcessor -from agents.tracing.span_data import ( - AgentSpanData, - FunctionSpanData, - GenerationSpanData, - HandoffSpanData, - ResponseSpanData, -) -from microsoft_agents_a365.observability.core.constants import ( - CHAT_OPERATION_NAME, - CUSTOM_PARENT_SPAN_ID_KEY, - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_TOOL_CALL_ID_KEY, - GEN_AI_TOOL_TYPE_KEY, - INVOKE_AGENT_OPERATION_NAME, -) -from microsoft_agents_a365.observability.core.utils import as_utc_nano, safe_json_dumps -from opentelemetry import trace as ot_trace -from opentelemetry.context import attach, detach -from opentelemetry.trace import Span as OtelSpan -from opentelemetry.trace import ( - Status, - StatusCode, - Tracer, - set_span_in_context, -) - -from openai.types.responses import ( - Response, -) - -from .constants import ( - GEN_AI_GRAPH_NODE_PARENT_ID, -) -from .message_mapper import map_input_messages, map_output_messages -from .utils import ( - capture_input_message, - capture_output_message, - capture_tool_call_ids, - find_ancestor_agent_span_id, - get_attributes_from_function_span_data, - get_attributes_from_generation_span_data, - get_attributes_from_input, - get_attributes_from_mcp_list_tool_span_data, - get_attributes_from_response, - get_span_kind, - get_span_name, - get_span_status, - get_tool_call_id, -) - -logger = logging.getLogger(__name__) - - -""" -Custom Trace Processor for OpenAI Agents SDK -""" - - -class OpenAIAgentsTraceProcessor(TracingProcessor): - _MAX_HANDOFFS_IN_FLIGHT = 1000 - _MAX_PENDING_TOOL_CALLS = 1000 - _MAX_TRACKED_SPANS = 10000 - - def __init__(self, tracer: Tracer) -> None: - self._tracer = tracer - self._root_spans: OrderedDict[str, OtelSpan] = OrderedDict() - self._otel_spans: OrderedDict[str, OtelSpan] = OrderedDict() - self._tokens: OrderedDict[str, object] = OrderedDict() - # This captures in flight handoff. Once the handoff is complete, the entry is deleted - # If the handoff does not complete, the entry stays in the dict. - # Use an OrderedDict and _MAX_HANDOFFS_IN_FLIGHT to cap the size of the dict - # in case there are large numbers of orphaned handoffs - self._reverse_handoffs_dict: OrderedDict[str, str] = OrderedDict() - # Track input/output messages for agent spans (keyed by agent span_id) - self._agent_inputs: OrderedDict[str, str] = OrderedDict() - self._agent_outputs: OrderedDict[str, str] = OrderedDict() - # Track agent span IDs to find nearest ancestor - self._agent_span_ids: OrderedDict[str, None] = OrderedDict() - # Track parent-child relationships: child_span_id -> parent_span_id - self._span_parents: OrderedDict[str, str] = OrderedDict() - # Track tool_call_ids from GenerationSpan: (function_name, trace_id) -> call_id - # Use an OrderedDict and _MAX_PENDING_TOOL_CALLS to cap the size of the dict - # in case tool calls are captured but never consumed - self._pending_tool_calls: OrderedDict[str, str] = OrderedDict() - - # helper - def _stamp_custom_parent(self, otel_span: OtelSpan, trace_id: str) -> None: - root = self._root_spans.get(trace_id) - if not root: - return - sc = root.get_span_context() - pid_hex = "0x" + ot_trace.format_span_id(sc.span_id) - otel_span.set_attribute(CUSTOM_PARENT_SPAN_ID_KEY, pid_hex) - - @staticmethod - def _cap_ordered_dict(d: OrderedDict, max_size: int) -> None: - """Evict oldest entries from an OrderedDict to stay within max_size.""" - while len(d) > max_size: - d.popitem(last=False) - - def on_trace_start(self, trace: Trace) -> None: - """Called when a trace is started. - - Args: - trace: The trace that started. - """ - - def on_trace_end(self, trace: Trace) -> None: - """Called when a trace is finished. - - Args: - trace: The trace that started. - """ - if root_span := self._root_spans.pop(trace.trace_id, None): - root_span.set_status(Status(StatusCode.OK)) - root_span.end() - - def on_span_start(self, span: Span[Any]) -> None: - """Called when a span is started. - - Args: - span: The span that started. - """ - if not span.started_at: - return - start_time = datetime.fromisoformat(span.started_at) - parent_span = ( - self._otel_spans.get(span.parent_id) - if span.parent_id - else self._root_spans.get(span.trace_id) - ) - context = set_span_in_context(parent_span) if parent_span else None - span_name = get_span_name(span) - otel_span = self._tracer.start_span( - name=span_name, - context=context, - start_time=as_utc_nano(start_time), - attributes={ - GEN_AI_OPERATION_NAME_KEY: get_span_kind(span.span_data), - GEN_AI_PROVIDER_NAME_KEY: "openai", - }, - ) - self._otel_spans[span.span_id] = otel_span - self._cap_ordered_dict(self._otel_spans, self._MAX_TRACKED_SPANS) - self._tokens[span.span_id] = attach(set_span_in_context(otel_span)) - self._cap_ordered_dict(self._tokens, self._MAX_TRACKED_SPANS) - # Track parent-child relationship for ancestor lookup - if span.parent_id: - self._span_parents[span.span_id] = span.parent_id - self._cap_ordered_dict(self._span_parents, self._MAX_TRACKED_SPANS) - # Track AgentSpan IDs - if isinstance(span.span_data, AgentSpanData): - self._agent_span_ids[span.span_id] = None - self._cap_ordered_dict(self._agent_span_ids, self._MAX_TRACKED_SPANS) - - def on_span_end(self, span: Span[Any]) -> None: - """Called when a span is finished. Should not block or raise exceptions. - - Args: - span: The span that finished. - """ - if token := self._tokens.pop(span.span_id, None): - detach(token) # type: ignore[arg-type] - # Clean up parent tracking - self._span_parents.pop(span.span_id, None) - if not (otel_span := self._otel_spans.pop(span.span_id, None)): - return - otel_span.update_name(get_span_name(span)) - - data = span.span_data - - # DATA TYPES AS PER OPENAI AGENTS SDK - if isinstance(data, ResponseSpanData): - if hasattr(data, "response") and isinstance(response := data.response, Response): - otel_span.set_attribute(GEN_AI_OUTPUT_MESSAGES_KEY, response.model_dump_json()) - for k, v in get_attributes_from_response(response): - otel_span.set_attribute(k, v) - if hasattr(data, "input") and (input := data.input): - if isinstance(input, str): - otel_span.set_attribute(GEN_AI_INPUT_MESSAGES_KEY, input) - elif isinstance(input, list): - otel_span.set_attribute(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(input)) - for k, v in get_attributes_from_input(input): - otel_span.set_attribute(k, v) - elif TYPE_CHECKING: - assert_never(input) - elif isinstance(data, GenerationSpanData): - for k, v in get_attributes_from_generation_span_data(data): - otel_span.set_attribute(k, v) - self._stamp_custom_parent(otel_span, span.trace_id) - # Capture input/output messages for nearest ancestor agent span - if agent_span_id := find_ancestor_agent_span_id( - span.parent_id, self._agent_span_ids, self._span_parents - ): - if data.input: - capture_input_message(agent_span_id, data.input, self._agent_inputs) - self._cap_ordered_dict(self._agent_inputs, self._MAX_TRACKED_SPANS) - if data.output: - capture_output_message(agent_span_id, data.output, self._agent_outputs) - self._cap_ordered_dict(self._agent_outputs, self._MAX_TRACKED_SPANS) - # Capture tool_call_ids for later use by FunctionSpan - if data.output: - capture_tool_call_ids( - data.output, self._pending_tool_calls, self._MAX_PENDING_TOOL_CALLS - ) - otel_span.update_name( - f"{otel_span.attributes[GEN_AI_OPERATION_NAME_KEY]} {otel_span.attributes[GEN_AI_REQUEST_MODEL_KEY]}" - ) - elif isinstance(data, FunctionSpanData): - for k, v in get_attributes_from_function_span_data(data): - otel_span.set_attribute(k, v) - self._stamp_custom_parent(otel_span, span.trace_id) - otel_span.set_attribute(GEN_AI_TOOL_TYPE_KEY, data.type) - # Set tool_call_id if available from preceding GenerationSpan - func_args = data.input if data.input else "" - if tool_call_id := get_tool_call_id(data.name, func_args, self._pending_tool_calls): - otel_span.set_attribute(GEN_AI_TOOL_CALL_ID_KEY, tool_call_id) - otel_span.update_name(f"{EXECUTE_TOOL_OPERATION_NAME} {data.name}") - elif isinstance(data, MCPListToolsSpanData): - for k, v in get_attributes_from_mcp_list_tool_span_data(data): - otel_span.set_attribute(k, v) - elif isinstance(data, HandoffSpanData): - # Set this dict to find the parent node when the agent span starts - if data.to_agent and data.from_agent: - key = f"{data.to_agent}:{span.trace_id}" - self._reverse_handoffs_dict[key] = data.from_agent - # Cap the size of the dict - while len(self._reverse_handoffs_dict) > self._MAX_HANDOFFS_IN_FLIGHT: - self._reverse_handoffs_dict.popitem(last=False) - elif isinstance(data, AgentSpanData): - # Lookup the parent node if exists - key = f"{data.name}:{span.trace_id}" - if parent_node := self._reverse_handoffs_dict.pop(key, None): - otel_span.set_attribute(GEN_AI_GRAPH_NODE_PARENT_ID, parent_node) - # Apply captured input/output messages from child spans - if input_msg := self._agent_inputs.pop(span.span_id, None): - otel_span.set_attribute(GEN_AI_INPUT_MESSAGES_KEY, input_msg) - if output_msg := self._agent_outputs.pop(span.span_id, None): - otel_span.set_attribute(GEN_AI_OUTPUT_MESSAGES_KEY, output_msg) - otel_span.update_name(f"{INVOKE_AGENT_OPERATION_NAME} {get_span_name(span)}") - # Clean up tracking - self._agent_span_ids.pop(span.span_id, None) - - # Map raw messages to A365 versioned format before ending the span - self._apply_message_mapping(otel_span) - - end_time: int | None = None - if span.ended_at: - try: - end_time = as_utc_nano(datetime.fromisoformat(span.ended_at)) - except ValueError: - pass - otel_span.set_status(status=get_span_status(span)) - otel_span.end(end_time) - - @staticmethod - def _apply_message_mapping(otel_span: OtelSpan) -> None: - """Map raw ``gen_ai.input/output.messages`` to the A365 versioned format.""" - attrs = otel_span.attributes or {} - operation = attrs.get(GEN_AI_OPERATION_NAME_KEY, "") - if operation not in (INVOKE_AGENT_OPERATION_NAME, CHAT_OPERATION_NAME): - return - - raw_input = attrs.get(GEN_AI_INPUT_MESSAGES_KEY) - if raw_input and isinstance(raw_input, str): - mapped = map_input_messages(raw_input) - if mapped is not None: - otel_span.set_attribute(GEN_AI_INPUT_MESSAGES_KEY, mapped) - - raw_output = attrs.get(GEN_AI_OUTPUT_MESSAGES_KEY) - if raw_output and isinstance(raw_output, str): - mapped = map_output_messages(raw_output) - if mapped is not None: - otel_span.set_attribute(GEN_AI_OUTPUT_MESSAGES_KEY, mapped) - - def force_flush(self) -> None: - """Forces an immediate flush of all queued spans/traces.""" - pass - - def shutdown(self) -> None: - """Called when the application stops.""" - pass diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py b/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py deleted file mode 100644 index 170a1d99..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py +++ /dev/null @@ -1,633 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# -------------------------------------------------- # -# HELPER FUNCTIONS ### -# -------------------------------------------------- # - -from collections.abc import Iterable, Iterator, Mapping -from typing import TYPE_CHECKING, Any, assert_never - -from agents import MCPListToolsSpanData -from agents.tracing import Span -from agents.tracing.span_data import ( - AgentSpanData, - CustomSpanData, - FunctionSpanData, - GenerationSpanData, - GuardrailSpanData, - HandoffSpanData, - ResponseSpanData, - SpanData, -) -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_CHOICE, - GEN_AI_EXECUTION_PAYLOAD_KEY, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_RESPONSE_FINISH_REASONS_KEY, - GEN_AI_RESPONSE_MODEL_KEY, - GEN_AI_TOOL_ARGS_KEY, - GEN_AI_TOOL_CALL_ID_KEY, - GEN_AI_TOOL_CALL_RESULT_KEY, - GEN_AI_TOOL_NAME_KEY, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - GEN_AI_USAGE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, -) -from microsoft_agents_a365.observability.core.utils import safe_json_dumps -from opentelemetry.trace import ( - Status, - StatusCode, -) -from opentelemetry.util.types import AttributeValue - -from openai.types.responses import ( - EasyInputMessageParam, - FunctionTool, - Response, - ResponseCustomToolCall, - ResponseCustomToolCallOutputParam, - ResponseCustomToolCallParam, - ResponseFunctionToolCall, - ResponseFunctionToolCallParam, - ResponseInputContentParam, - ResponseInputItemParam, - ResponseOutputItem, - ResponseOutputMessage, - ResponseOutputMessageParam, - ResponseOutputRefusal, - ResponseOutputText, - ResponseUsage, - Tool, -) -from openai.types.responses.response_input_item_param import FunctionCallOutput, Message -from openai.types.responses.response_output_message_param import Content - -from .constants import ( - GEN_AI_LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, - GEN_AI_LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHED_READ, - GEN_AI_LLM_TOKEN_COUNT_TOTAL, - GEN_AI_MESSAGE_CONTENT, - GEN_AI_MESSAGE_CONTENT_TYPE, - GEN_AI_MESSAGE_CONTENTS, - GEN_AI_MESSAGE_ROLE, - GEN_AI_MESSAGE_TOOL_CALL_ID, - GEN_AI_MESSAGE_TOOL_CALL_NAME, - GEN_AI_MESSAGE_TOOL_CALLS, - GEN_AI_SPAN_KIND_AGENT_KEY, - GEN_AI_SPAN_KIND_CHAIN_KEY, - GEN_AI_SPAN_KIND_LLM_KEY, - GEN_AI_SPAN_KIND_TOOL_KEY, - GEN_AI_TOOL_JSON_SCHEMA, -) - - -def get_span_name(obj: Span[Any]) -> str: - if hasattr(data := obj.span_data, "name") and isinstance(name := data.name, str): - return name - if isinstance(obj.span_data, HandoffSpanData) and obj.span_data.to_agent: - return f"handoff to {obj.span_data.to_agent}" - return obj.span_data.type # type: ignore[no-any-return] - - -def get_span_kind(obj: SpanData) -> str: - if isinstance(obj, AgentSpanData): - return GEN_AI_SPAN_KIND_AGENT_KEY - if isinstance(obj, FunctionSpanData): - return GEN_AI_SPAN_KIND_TOOL_KEY - if isinstance(obj, GenerationSpanData): - return GEN_AI_SPAN_KIND_LLM_KEY - if isinstance(obj, ResponseSpanData): - return GEN_AI_SPAN_KIND_LLM_KEY - if isinstance(obj, HandoffSpanData): - return GEN_AI_SPAN_KIND_TOOL_KEY - if isinstance(obj, CustomSpanData): - return GEN_AI_SPAN_KIND_CHAIN_KEY - if isinstance(obj, GuardrailSpanData): - return GEN_AI_SPAN_KIND_CHAIN_KEY - return GEN_AI_SPAN_KIND_CHAIN_KEY - - -def get_attributes_from_input( - obj: Iterable[ResponseInputItemParam], - msg_idx: int = 1, -) -> Iterator[tuple[str, AttributeValue]]: - for i, item in enumerate(obj, msg_idx): - prefix = f"{GEN_AI_INPUT_MESSAGES_KEY}.{i}." - if "type" not in item: - if "role" in item and "content" in item: - yield from get_attributes_from_message_param( - { # type: ignore[misc, arg-type] - "type": "message", - "role": item["role"], # type: ignore[typeddict-item] - "content": item["content"], # type: ignore[typeddict-item] - }, - prefix, - ) - elif item["type"] == "message": - yield from get_attributes_from_message_param(item, prefix) - elif item["type"] == "function_call": - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", "assistant" - yield from get_attributes_from_response_function_tool_call_param( - item, - f"{prefix}{GEN_AI_MESSAGE_TOOL_CALLS}.0.", - ) - elif item["type"] == "function_call_output": - yield from get_attributes_from_function_call_output(item, prefix) - elif item["type"] == "custom_tool_call": - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", "assistant" - yield from get_attributes_from_response_custom_tool_call_param( - item, - f"{prefix}{GEN_AI_MESSAGE_TOOL_CALLS}.0.", - ) - elif item["type"] == "custom_tool_call_output": - yield from get_attributes_from_response_custom_tool_call_output_param(item, prefix) - elif TYPE_CHECKING and item["type"] is not None: - assert_never(item["type"]) - - -def get_attributes_from_message_param( - obj: EasyInputMessageParam | Message | ResponseOutputMessageParam, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", obj["role"] - if content := obj.get("content"): - if isinstance(content, str): - yield f"{prefix}{GEN_AI_MESSAGE_CONTENT}", content - elif isinstance(content, list): - yield from get_attributes_from_message_content_list(content, prefix) - - -def get_attributes_from_response_function_tool_call_param( - obj: ResponseFunctionToolCallParam, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - yield f"{prefix}{GEN_AI_MESSAGE_TOOL_CALL_ID}", obj["call_id"] - yield f"{prefix}{GEN_AI_MESSAGE_TOOL_CALL_NAME}", obj["name"] - if obj["arguments"] != "{}": - yield f"{prefix}{GEN_AI_TOOL_ARGS_KEY}", obj["arguments"] - - -def get_attributes_from_response_custom_tool_call_param( - obj: ResponseCustomToolCallParam, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - if (call_id := obj.get("call_id")) is not None: - yield f"{prefix}{GEN_AI_TOOL_CALL_ID_KEY}", call_id - if (name := obj.get("name")) is not None: - yield f"{prefix}{GEN_AI_TOOL_NAME_KEY}", name - if (input_data := obj.get("input")) is not None: - yield ( - f"{prefix}{GEN_AI_TOOL_ARGS_KEY}", - safe_json_dumps({"input": input_data}), - ) - - -def get_attributes_from_response_custom_tool_call_output_param( - obj: ResponseCustomToolCallOutputParam, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", "tool" - if (call_id := obj.get("call_id")) is not None: - yield f"{prefix}{GEN_AI_TOOL_CALL_ID_KEY}", call_id - if (output := obj.get("output")) is not None: - yield f"{prefix}{GEN_AI_TOOL_CALL_RESULT_KEY}", output - - -def get_attributes_from_function_call_output( - obj: FunctionCallOutput, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", "tool" - yield f"{prefix}{GEN_AI_TOOL_CALL_ID_KEY}", obj["call_id"] - yield f"{prefix}{GEN_AI_TOOL_CALL_RESULT_KEY}", obj["output"] - - -def get_attributes_from_generation_span_data( - obj: GenerationSpanData, -) -> Iterator[tuple[str, AttributeValue]]: - yield GEN_AI_PROVIDER_NAME_KEY, "openai" - if isinstance(model := obj.model, str): - yield GEN_AI_REQUEST_MODEL_KEY, model - if isinstance(obj.model_config, dict) and ( - param := {k: v for k, v in obj.model_config.items() if v is not None} - ): - yield GEN_AI_EXECUTION_PAYLOAD_KEY, safe_json_dumps(param) - yield from _get_attributes_from_chat_completions_input(obj.input) - yield from _get_attributes_from_chat_completions_output(obj.output) - yield from _get_attributes_from_chat_completions_usage(obj.usage) - - -def get_attributes_from_mcp_list_tool_span_data( - obj: MCPListToolsSpanData, -) -> Iterator[tuple[str, AttributeValue]]: - yield GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(obj.result) - - -def _get_attributes_from_chat_completions_input( - obj: Iterable[Mapping[str, Any]] | None, -) -> Iterator[tuple[str, AttributeValue]]: - if not obj: - return - try: - yield GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(obj) - except Exception: - pass - yield from get_attributes_from_chat_completions_message_dicts( - obj, - f"{GEN_AI_INPUT_MESSAGES_KEY}.", - ) - - -def _get_attributes_from_chat_completions_output( - obj: Iterable[Mapping[str, Any]] | None, -) -> Iterator[tuple[str, AttributeValue]]: - if not obj: - return - try: - yield GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(obj) - except Exception: - pass - - # Collect all finish_reason values - finish_reasons = [ - message.get("finish_reason") for message in obj if message.get("finish_reason") is not None - ] - if finish_reasons: - yield GEN_AI_RESPONSE_FINISH_REASONS_KEY, ",".join(finish_reasons) - - yield from get_attributes_from_chat_completions_message_dicts( - obj, - f"{GEN_AI_OUTPUT_MESSAGES_KEY}.", - ) - - -def get_attributes_from_chat_completions_message_dicts( - obj: Iterable[Mapping[str, Any]], - prefix: str = "", - msg_idx: int = 0, - tool_call_idx: int = 0, -) -> Iterator[tuple[str, AttributeValue]]: - if not isinstance(obj, Iterable): - return - for msg in obj: - if isinstance(role := msg.get("role"), str): - yield f"{prefix}{msg_idx}.{GEN_AI_MESSAGE_ROLE}", role - if content := msg.get("content"): - yield from get_attributes_from_chat_completions_message_content( - content, - f"{prefix}{msg_idx}.", - ) - if isinstance(tool_call_id := msg.get("tool_call_id"), str): - yield f"{prefix}{msg_idx}.{GEN_AI_MESSAGE_TOOL_CALL_ID}", tool_call_id - if isinstance(tool_calls := msg.get("tool_calls"), Iterable): - for tc in tool_calls: - yield from _get_attributes_from_chat_completions_tool_call_dict( - tc, - f"{prefix}{msg_idx}.{GEN_AI_MESSAGE_TOOL_CALLS}.{tool_call_idx}.", - ) - tool_call_idx += 1 - msg_idx += 1 - - -def get_attributes_from_chat_completions_message_content( - obj: str | Iterable[Mapping[str, Any]], - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - if isinstance(obj, str): - yield f"{prefix}{GEN_AI_MESSAGE_CONTENT}", obj - elif isinstance(obj, Iterable): - for i, item in enumerate(obj): - if not isinstance(item, Mapping): - continue - yield from _get_attributes_from_chat_completions_message_content_item( - item, - f"{prefix}{GEN_AI_MESSAGE_CONTENTS}.{i}.", - ) - - -def _get_attributes_from_chat_completions_message_content_item( - obj: Mapping[str, Any], - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - if obj.get("type") == "text" and (text := obj.get("text")): - yield f"{prefix}{GEN_AI_OUTPUT_MESSAGES_KEY}", text - - -def _get_attributes_from_chat_completions_tool_call_dict( - obj: Mapping[str, Any], - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - if id_ := obj.get("id"): - yield f"{prefix}{GEN_AI_TOOL_CALL_ID_KEY}", id_ - if function := obj.get("function"): - if name := function.get("name"): - yield f"{prefix}{GEN_AI_TOOL_NAME_KEY}", name - if arguments := function.get("arguments"): - if arguments != "{}": - yield f"{prefix}{GEN_AI_TOOL_ARGS_KEY}", arguments - - -def _get_attributes_from_chat_completions_usage( - obj: Mapping[str, Any] | None, -) -> Iterator[tuple[str, AttributeValue]]: - if not obj: - return - if input_tokens := obj.get("input_tokens"): - yield GEN_AI_USAGE_INPUT_TOKENS_KEY, input_tokens - if output_tokens := obj.get("output_tokens"): - yield GEN_AI_USAGE_OUTPUT_TOKENS_KEY, output_tokens - - -def _convert_to_primitive(value: Any) -> bool | str | bytes | int | float: - if isinstance(value, (bool, str, bytes, int, float)): - return value - if isinstance(value, (list, tuple)): - return safe_json_dumps(value) - if isinstance(value, dict): - return safe_json_dumps(value) - return str(value) - - -def get_attributes_from_function_span_data( - obj: FunctionSpanData, -) -> Iterator[tuple[str, AttributeValue]]: - yield GEN_AI_TOOL_NAME_KEY, obj.name - if obj.input: - yield GEN_AI_TOOL_ARGS_KEY, obj.input - if obj.output is not None: - yield GEN_AI_TOOL_CALL_RESULT_KEY, _convert_to_primitive(obj.output) - - -def get_attributes_from_message_content_list( - obj: Iterable[ResponseInputContentParam | Content], - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - for i, item in enumerate(obj): - if item["type"] == "input_text" or item["type"] == "output_text": - yield f"{prefix}{GEN_AI_INPUT_MESSAGES_KEY}.{i}.{GEN_AI_MESSAGE_CONTENT_TYPE}", "text" - yield ( - f"{prefix}{GEN_AI_INPUT_MESSAGES_KEY}.{i}.{GEN_AI_OUTPUT_MESSAGES_KEY}", - item["text"], - ) - elif item["type"] == "refusal": - yield f"{prefix}{GEN_AI_INPUT_MESSAGES_KEY}.{i}.{GEN_AI_MESSAGE_CONTENT_TYPE}", "text" - yield ( - f"{prefix}{GEN_AI_INPUT_MESSAGES_KEY}.{i}.{GEN_AI_OUTPUT_MESSAGES_KEY}", - item["refusal"], - ) - elif TYPE_CHECKING: - assert_never(item["type"]) - - -def get_attributes_from_response(obj: Response) -> Iterator[tuple[str, AttributeValue]]: - yield from get_attributes_from_tools(obj.tools) - yield from get_attributes_from_usage(obj.usage) - yield from get_attributes_from_response_output(obj.output) - if isinstance(obj.instructions, str): - yield from _get_attributes_from_response_instruction(obj.instructions) - else: - pass # TODO: handle list instructions - yield GEN_AI_RESPONSE_MODEL_KEY, obj.model - param = obj.model_dump( - exclude_none=True, - exclude={"object", "tools", "usage", "output", "error", "status"}, - ) - yield GEN_AI_EXECUTION_PAYLOAD_KEY, safe_json_dumps(param) - - -def get_attributes_from_tools( - tools: Iterable[Tool] | None, -) -> Iterator[tuple[str, AttributeValue]]: - if not tools: - return - for i, tool in enumerate(tools): - if isinstance(tool, FunctionTool): - yield ( - f"{GEN_AI_CHOICE}.{i}.{GEN_AI_TOOL_JSON_SCHEMA}", - safe_json_dumps( - { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "strict": tool.strict, - }, - } - ), - ) - else: - pass - - -def get_attributes_from_response_output( - obj: Iterable[ResponseOutputItem], - msg_idx: int = 0, -) -> Iterator[tuple[str, AttributeValue]]: - tool_call_idx = 0 - for _i, item in enumerate(obj): - if item.type == "message": - prefix = f"{GEN_AI_OUTPUT_MESSAGES_KEY}.{msg_idx}." - yield from _get_attributes_from_message(item, prefix) - msg_idx += 1 - elif item.type == "function_call": - yield f"{GEN_AI_OUTPUT_MESSAGES_KEY}.{msg_idx}.{GEN_AI_MESSAGE_ROLE}", "assistant" - prefix = f"{GEN_AI_OUTPUT_MESSAGES_KEY}.{msg_idx}.{GEN_AI_MESSAGE_TOOL_CALLS}.{tool_call_idx}." - yield from _get_attributes_from_function_tool_call(item, prefix) - tool_call_idx += 1 - elif item.type == "custom_tool_call": - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", "assistant" - yield from _get_attributes_from_response_custom_tool_call( - item, - f"{prefix}{GEN_AI_MESSAGE_TOOL_CALLS}.0.", - ) - elif TYPE_CHECKING: - assert_never(item) - - -def _get_attributes_from_response_instruction( - instructions: str | None, -) -> Iterator[tuple[str, AttributeValue]]: - if not instructions: - return - yield f"{GEN_AI_INPUT_MESSAGES_KEY}.0.{GEN_AI_MESSAGE_ROLE}", "system" - yield f"{GEN_AI_INPUT_MESSAGES_KEY}.0.{GEN_AI_OUTPUT_MESSAGES_KEY}", instructions - - -def _get_attributes_from_function_tool_call( - obj: ResponseFunctionToolCall, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - yield f"{prefix}{GEN_AI_TOOL_CALL_ID_KEY}", obj.call_id - yield f"{prefix}{GEN_AI_TOOL_NAME_KEY}", obj.name - if obj.arguments != "{}": - yield f"{prefix}{GEN_AI_TOOL_ARGS_KEY}", obj.arguments - - -def _get_attributes_from_response_custom_tool_call( - obj: ResponseCustomToolCall, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - if (call_id := obj.call_id) is not None: - yield f"{prefix}{GEN_AI_TOOL_CALL_ID_KEY}", call_id - if (name := obj.name) is not None: - yield f"{prefix}{GEN_AI_TOOL_NAME_KEY}", name - if (input_data := obj.input) is not None: - yield ( - f"{prefix}{GEN_AI_TOOL_ARGS_KEY}", - safe_json_dumps({"input": input_data}), - ) - - -def _get_attributes_from_message( - obj: ResponseOutputMessage, - prefix: str = "", -) -> Iterator[tuple[str, AttributeValue]]: - yield f"{prefix}{GEN_AI_MESSAGE_ROLE}", obj.role - for i, item in enumerate(obj.content): - if isinstance(item, ResponseOutputText): - yield f"{prefix}{GEN_AI_OUTPUT_MESSAGES_KEY}.{i}.{GEN_AI_MESSAGE_CONTENT_TYPE}", "text" - yield ( - f"{prefix}{GEN_AI_OUTPUT_MESSAGES_KEY}.{i}.{GEN_AI_OUTPUT_MESSAGES_KEY}", - item.text, - ) - elif isinstance(item, ResponseOutputRefusal): - yield f"{prefix}{GEN_AI_OUTPUT_MESSAGES_KEY}.{i}.{GEN_AI_MESSAGE_CONTENT_TYPE}", "text" - yield ( - f"{prefix}{GEN_AI_OUTPUT_MESSAGES_KEY}.{i}.{GEN_AI_OUTPUT_MESSAGES_KEY}", - item.refusal, - ) - elif TYPE_CHECKING: - assert_never(item) - - -def get_attributes_from_usage( - obj: ResponseUsage | None, -) -> Iterator[tuple[str, AttributeValue]]: - if not obj: - return - yield GEN_AI_USAGE_OUTPUT_TOKENS_KEY, obj.output_tokens - yield GEN_AI_USAGE_INPUT_TOKENS_KEY, obj.input_tokens - yield GEN_AI_LLM_TOKEN_COUNT_TOTAL, obj.total_tokens - yield GEN_AI_LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHED_READ, obj.input_tokens_details.cached_tokens - if obj.input_tokens_details.cached_tokens is not None: - yield ( - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - obj.input_tokens_details.cached_tokens, - ) - yield ( - GEN_AI_LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, - obj.output_tokens_details.reasoning_tokens, - ) - - -def get_span_status(obj: Span[Any]) -> Status: - if error := getattr(obj, "error", None): - return Status( - status_code=StatusCode.ERROR, description=f"{error.get('message')}: {error.get('data')}" - ) - else: - return Status(StatusCode.OK) - - -def capture_tool_call_ids( - output_list: Any, pending_tool_calls: dict[str, str], max_size: int = 1000 -) -> None: - """Extract and store tool_call_ids from generation output for later use by FunctionSpan. - - Args: - output_list: The generation output containing tool calls - pending_tool_calls: OrderedDict to store pending tool calls - max_size: Maximum number of pending tool calls to keep in memory - """ - if not output_list: - return - try: - for msg in output_list: - if isinstance(msg, dict) and msg.get("role") == "assistant": - tool_calls = msg.get("tool_calls") - if tool_calls: - for tc in tool_calls: - if isinstance(tc, dict): - call_id = tc.get("id") - func = tc.get("function", {}) - func_name = func.get("name") if isinstance(func, dict) else None - func_args = func.get("arguments", "") if isinstance(func, dict) else "" - if call_id and func_name: - # Key by (function_name, arguments) to uniquely identify each call - key = f"{func_name}:{func_args}" - pending_tool_calls[key] = call_id - # Cap the size of the dict to prevent unbounded growth - while len(pending_tool_calls) > max_size: - pending_tool_calls.popitem(last=False) - except Exception: - pass - - -def get_tool_call_id( - function_name: str, function_args: str, pending_tool_calls: dict[str, str] -) -> str | None: - """Get and remove the tool_call_id for a function with specific arguments.""" - key = f"{function_name}:{function_args}" - return pending_tool_calls.pop(key, None) - - -def capture_input_message( - parent_span_id: str, input_list: Any, agent_inputs: dict[str, str] -) -> None: - """Extract and store the first user message from input list for parent agent span.""" - if parent_span_id in agent_inputs: - return # Already captured - if not input_list: - return - try: - for msg in input_list: - if isinstance(msg, dict) and msg.get("role") == "user": - content = msg.get("content", "") - if content: - agent_inputs[parent_span_id] = str(content) - return - except Exception: - pass - - -def capture_output_message( - parent_span_id: str, output_list: Any, agent_outputs: dict[str, str] -) -> None: - """Extract and store the last assistant message with actual content (no tool calls) for parent agent span.""" - if not output_list: - return - try: - # Iterate in reverse to get the last assistant message with content (not a tool call) - output_items = list(output_list) if not isinstance(output_list, list) else output_list - for msg in reversed(output_items): - if isinstance(msg, dict) and msg.get("role") == "assistant": - content = msg.get("content") - tool_calls = msg.get("tool_calls") - # Only capture if there's actual content and no tool_calls - # (tool_calls means this is an intermediate step, not the final response) - if content and not tool_calls: - agent_outputs[parent_span_id] = str(content) - return - except Exception: - pass - - -def find_ancestor_agent_span_id( - span_id: str | None, - agent_span_ids: set[str] | Mapping[str, object], - span_parents: Mapping[str, str], -) -> str | None: - """Walk up the parent chain to find the nearest ancestor AgentSpan.""" - current = span_id - visited: set[str] = set() # Prevent infinite loops - while current and current not in visited: - if current in agent_span_ids: - return current - visited.add(current) - current = span_parents.get(current) - return None diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml deleted file mode 100644 index e3d6acae..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml +++ /dev/null @@ -1,84 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] -build-backend = "build_backend" - -[project] -name = "microsoft-agents-a365-observability-extensions-openai" -dynamic = ["version"] -authors = [ - { name = "Microsoft", email = "support@microsoft.com" }, -] -description = "OpenAI Agents SDK observability and tracing extensions for Microsoft Agent 365" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", -] -license = {text = "MIT"} -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "openai", "agents", "ai"] -dependencies = [ - "microsoft-agents-a365-observability-core", - "openai-agents", - "opentelemetry-api", - "opentelemetry-sdk", - "opentelemetry-instrumentation", - # Publish security floors for the framework's transitive dependencies. - "mcp", - "anyio", - "python-multipart", -] - -[project.urls] -Homepage = "https://github.com/microsoft/Agent365-python" -Repository = "https://github.com/microsoft/Agent365-python" -Issues = "https://github.com/microsoft/Agent365-python/issues" -Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-extensions-openai" - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "ruff", - "black", - "mypy", -] -test = [ - "pytest", - "pytest-asyncio", -] - -[tool.setuptools.packages.find] -where = ["."] - -[tool.setuptools] -license-files = ["../../LICENSE"] -include-package-data = true - -[tool.setuptools.package-data] -"*" = ["../../LICENSE"] - -[tool.black] -line-length = 100 -target-version = ['py311'] - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint.flake8-copyright] -notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." -min-file-size = 1 - -[tool.mypy] -python_version = "3.11" -strict = true -warn_return_any = true -warn_unused_configs = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py b/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py deleted file mode 100644 index 8fbb2a49..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import sys -from pathlib import Path -from os import environ -from setuptools import setup - -# Get version from environment variable set by CI/CD -package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") - -# Add versioning helper to path -helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" -sys.path.insert(0, str(helper_path)) - -from setup_utils import get_dynamic_dependencies # noqa: E402 - -# Use exact version matching for internal dependencies: -# - Internal packages get: == current_version (e.g., == 1.2.3) -# - Ensures all SDK packages must be at the same version -# - Prevents incompatibility issues from version mismatches -setup( - version=package_version, - install_requires=get_dynamic_dependencies( - use_exact_match=True, - ), -) diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/README.md b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/README.md deleted file mode 100644 index b0e63289..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# microsoft-agents-a365-observability-extensions-semantickernel - -[![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-observability-extensions-semantickernel?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-semantickernel) -[![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-observability-extensions-semantickernel?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-observability-extensions-semantickernel) - -Observability extensions for Semantic Kernel framework. This package provides OpenTelemetry tracing integration for Semantic Kernel-based applications with automatic instrumentation for kernel functions, plugins, and planners. - -## Installation - -```bash -pip install microsoft-agents-a365-observability-extensions-semantickernel -``` - -## Usage - -For usage examples and detailed documentation, see the [Observability documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=python) on Microsoft Learn. - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-python/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details. diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/docs/design.md b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/docs/design.md deleted file mode 100644 index 0e149e63..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/docs/design.md +++ /dev/null @@ -1,59 +0,0 @@ -# Observability Extensions - Semantic Kernel - Design Document - -This document describes the architecture and design of the `microsoft-agents-a365-observability-extensions-semantickernel` package. - -## Overview - -This extension integrates the Agent365 observability infrastructure with Semantic Kernel, enabling automatic tracing of kernel function executions and LLM calls. - -## Key Components - -### SemanticKernelTraceInstrumentor - -The main instrumentor class that integrates with Semantic Kernel's telemetry. - -```python -from microsoft_agents_a365.observability.extensions.semantickernel import ( - SemanticKernelTraceInstrumentor -) - -# Initialize and instrument -instrumentor = SemanticKernelTraceInstrumentor() -instrumentor.instrument() -``` - -### SpanProcessor - -Custom span processor that captures Semantic Kernel-specific telemetry. - -### Integration Flow - -``` -Semantic Kernel Execution - │ - ▼ -TraceInstrumentor (intercepts kernel calls) - │ - ▼ -SpanProcessor (creates Agent365 spans) - │ - ▼ -InferenceScope / ExecuteToolScope - │ - ▼ -Agent365 Backend -``` - -## File Structure - -``` -microsoft_agents_a365/observability/extensions/semantickernel/ -├── __init__.py # Public API -├── trace_instrumentor.py # Main instrumentor -└── span_processor.py # Custom span processor -``` - -## Dependencies - -- `semantic-kernel` - Semantic Kernel framework -- `microsoft-agents-a365-observability-core` - Core observability diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py deleted file mode 100644 index b7c52582..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_enricher.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_enricher.py deleted file mode 100644 index 83f14213..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_enricher.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Span enricher for Semantic Kernel.""" - -from microsoft_agents_a365.observability.core.constants import ( - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_TOOL_ARGS_KEY, - GEN_AI_TOOL_CALL_RESULT_KEY, - INVOKE_AGENT_OPERATION_NAME, -) -from microsoft_agents_a365.observability.core.exporters.enriched_span import EnrichedReadableSpan -from opentelemetry.sdk.trace import ReadableSpan - -from .utils import extract_content_as_string_list - -# Semantic Kernel specific attribute keys -SK_TOOL_CALL_ARGUMENTS_KEY = "gen_ai.tool.call.arguments" -SK_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result" - - -def enrich_semantic_kernel_span(span: ReadableSpan) -> ReadableSpan: - """ - Enricher function for Semantic Kernel spans. - - Transforms SK-specific attributes to standard gen_ai attributes - before the span is exported. Enrichment is applied based on span type: - - invoke_agent spans: Extract only content from input/output messages - - execute_tool spans: Map tool arguments and results to standard keys - - Args: - span: The ReadableSpan to enrich. - - Returns: - The enriched span (wrapped if attributes were added), or the - original span if no enrichment was needed. - """ - extra_attributes = {} - attributes = span.attributes or {} - - # Only extract content for invoke_agent spans - if span.name.startswith(INVOKE_AGENT_OPERATION_NAME): - # Transform SK-specific agent invocation attributes to standard gen_ai attributes - # Extract only the content from the full message objects - # Support both gen_ai.agent.invocation_input and gen_ai.input_messages as sources - input_messages = attributes.get("gen_ai.agent.invocation_input") or attributes.get( - GEN_AI_INPUT_MESSAGES_KEY - ) - if input_messages: - extra_attributes[GEN_AI_INPUT_MESSAGES_KEY] = extract_content_as_string_list( - input_messages - ) - - output_messages = attributes.get("gen_ai.agent.invocation_output") or attributes.get( - GEN_AI_OUTPUT_MESSAGES_KEY - ) - if output_messages: - extra_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] = extract_content_as_string_list( - output_messages - ) - - # Map tool attributes for execute_tool spans - elif span.name.startswith(EXECUTE_TOOL_OPERATION_NAME): - if SK_TOOL_CALL_ARGUMENTS_KEY in attributes: - extra_attributes[GEN_AI_TOOL_ARGS_KEY] = attributes[SK_TOOL_CALL_ARGUMENTS_KEY] - - if SK_TOOL_CALL_RESULT_KEY in attributes: - extra_attributes[GEN_AI_TOOL_CALL_RESULT_KEY] = attributes[SK_TOOL_CALL_RESULT_KEY] - - if extra_attributes: - return EnrichedReadableSpan(span, extra_attributes) - - return span diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py deleted file mode 100644 index 63c6860f..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/span_processor.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_OPERATION_NAME_KEY, -) -from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType -from microsoft_agents_a365.observability.core.utils import extract_model_name -from opentelemetry import context as context_api -from opentelemetry.sdk.trace import ReadableSpan, Span -from opentelemetry.sdk.trace.export import SpanProcessor - - -class SemanticKernelSpanProcessor(SpanProcessor): - """ - SpanProcessor for SK - """ - - def __init__(self, service_name: str | None = None): - """ - Initialize the Semantic Kernel span processor. - - Args: - service_name: Optional service name for span enrichment. - """ - self.service_name = service_name - - def on_start(self, span: Span, parent_context: context_api.Context | None) -> None: - """ - Modify span while it's still writable. - - Args: - span: The span that is starting (writable). - parent_context: The parent context of the span. - """ - if span.name.startswith("chat."): - span.set_attribute(GEN_AI_OPERATION_NAME_KEY, InferenceOperationType.CHAT.value.lower()) - model_name = extract_model_name(span.name) - span.update_name(f"{InferenceOperationType.CHAT.value.lower()} {model_name}") - - def on_end(self, span: ReadableSpan) -> None: - """ - Called when a span ends. - """ - pass - - def shutdown(self) -> None: - """Shutdown the processor.""" - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - """Force flush any pending spans.""" - return True diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py deleted file mode 100644 index 643119ce..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/trace_instrumentor.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -from collections.abc import Collection -from typing import Any - -from microsoft_agents_a365.observability.core.config import ( - get_tracer_provider, - is_configured, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - register_span_enricher, - unregister_span_enricher, -) -from opentelemetry.instrumentation.instrumentor import BaseInstrumentor - -from microsoft_agents_a365.observability.extensions.semantickernel.span_enricher import ( - enrich_semantic_kernel_span, -) -from microsoft_agents_a365.observability.extensions.semantickernel.span_processor import ( - SemanticKernelSpanProcessor, -) - -_instruments = ("semantic-kernel >= 1.0.0",) - - -class SemanticKernelInstrumentor(BaseInstrumentor): - """ - Instruments Semantic Kernel with Agent365 observability. - """ - - def __init__(self): - if not is_configured(): - raise RuntimeError( - "Microsoft Agent 365 is not initialized. Call configure() before instrumenting." - ) - super().__init__() - - def instrumentation_dependencies(self) -> Collection[str]: - return _instruments - - def _instrument(self, **kwargs: Any) -> None: - """ - Instrument Semantic Kernel. - - Args: - **kwargs: Optional configuration parameters. - """ - provider = get_tracer_provider() - - # Add processor for on_start modifications (rename spans, add attributes) - self._processor = SemanticKernelSpanProcessor() - provider.add_span_processor(self._processor) - - # Register enricher for on_end modifications - # This enricher runs before the span is exported, allowing us to - # transform SK-specific attributes to standard gen_ai attributes - register_span_enricher(enrich_semantic_kernel_span) - - def _uninstrument(self, **kwargs: Any) -> None: - """ - Remove Semantic Kernel instrumentation. - - Args: - **kwargs: Optional configuration parameters. - """ - # Unregister the enricher - unregister_span_enricher() - - # Shutdown the processor - if hasattr(self, "_processor"): - self._processor.shutdown() diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/utils.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/utils.py deleted file mode 100644 index dd1515b7..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/microsoft_agents_a365/observability/extensions/semantickernel/utils.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Utility functions for Semantic Kernel observability extensions.""" - -from __future__ import annotations - -import json - - -def extract_content_as_string_list(messages_json: str) -> str: - """Extract content values from messages JSON and return as JSON string list. - - Transforms from: [{"role": "user", "content": "Hello"}] - To: ["Hello"] - - Args: - messages_json: JSON string like '[{"role": "user", "content": "Hello"}]' - - Returns: - JSON string containing only the content values as an array, - or the original string if parsing fails. - """ - try: - messages = json.loads(messages_json) - if isinstance(messages, list): - contents = [] - for msg in messages: - if isinstance(msg, dict) and "content" in msg: - contents.append(msg["content"]) - elif isinstance(msg, str): - contents.append(msg) - return json.dumps(contents) - return messages_json - except (json.JSONDecodeError, TypeError): - # If parsing fails, return as-is - return messages_json diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml deleted file mode 100644 index 1ba97661..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml +++ /dev/null @@ -1,84 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] -build-backend = "build_backend" - -[project] -name = "microsoft-agents-a365-observability-extensions-semantic-kernel" -dynamic = ["version"] -authors = [ - { name = "Microsoft", email = "support@microsoft.com" }, -] -description = "Semantic Kernel observability and tracing extensions for Microsoft Agent 365" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", -] -license = {text = "MIT"} -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "semantic-kernel", "agents", "ai"] -dependencies = [ - "microsoft-agents-a365-observability-core", - "semantic-kernel", - "opentelemetry-api", - "opentelemetry-sdk", - "opentelemetry-instrumentation", - # Publish security floors for the framework's transitive dependencies. - "mcp", - "anyio", - "python-multipart", -] - -[project.urls] -Homepage = "https://github.com/microsoft/Agent365-python" -Repository = "https://github.com/microsoft/Agent365-python" -Issues = "https://github.com/microsoft/Agent365-python/issues" -Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-extensions-semantickernel" - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "ruff", - "black", - "mypy", -] -test = [ - "pytest", - "pytest-asyncio", -] - -[tool.setuptools.packages.find] -where = ["."] - -[tool.setuptools] -license-files = ["../../LICENSE"] -include-package-data = true - -[tool.setuptools.package-data] -"*" = ["../../LICENSE"] - -[tool.black] -line-length = 100 -target-version = ['py311'] - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint.flake8-copyright] -notice-rgx = "# Copyright \\(c\\) Microsoft Corporation\\.\\r?\\n# Licensed under the MIT License\\." -min-file-size = 1 - -[tool.mypy] -python_version = "3.11" -strict = true -warn_return_any = true -warn_unused_configs = true diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py deleted file mode 100644 index 8fbb2a49..00000000 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import sys -from pathlib import Path -from os import environ -from setuptools import setup - -# Get version from environment variable set by CI/CD -package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") - -# Add versioning helper to path -helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" -sys.path.insert(0, str(helper_path)) - -from setup_utils import get_dynamic_dependencies # noqa: E402 - -# Use exact version matching for internal dependencies: -# - Internal packages get: == current_version (e.g., == 1.2.3) -# - Ensures all SDK packages must be at the same version -# - Prevents incompatibility issues from version mismatches -setup( - version=package_version, - install_requires=get_dynamic_dependencies( - use_exact_match=True, - ), -) diff --git a/libraries/microsoft-agents-a365-observability-hosting/README.md b/libraries/microsoft-agents-a365-observability-hosting/README.md deleted file mode 100644 index 8984b3e5..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Microsoft Agent 365 Observability Hosting Library - -This library provides hosting components for Agent 365 observability. - -## Installation - -```bash -pip install microsoft-agents-a365-observability-hosting -``` - -## App-only OBS token cache - -Agent 365 observability export is S2S-only. Use -`AgenticTokenCache.refresh_observability_token(agent_id, tenant_id, token_resolver)` -from the exporter token resolver to acquire and cache an app-only OBS token for -the exporting agent identity. - -```python -from microsoft_agents_a365.observability.hosting.token_cache_helpers import AgenticTokenCache - -cache = AgenticTokenCache() - - -async def acquire_app_only_obs_token(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - # Acquire a final app-only OBS token for agent_id and tenant_id. - ... - - -async def token_resolver(agent_id: str, tenant_id: str) -> str: - return await cache.refresh_observability_token(agent_id, tenant_id, acquire_app_only_obs_token) -``` - -The resolver receives the OBS `/.default` scope and must not perform user_fic or -OBO authentication. For blueprint-backed agents, acquire the final agent -identity app-only token before returning it; do not return the intermediate -blueprint assertion or a delegated workload token. The cache retries transient -acquisition failures, isolates entries by `(agent_id, tenant_id)`, respects JWT -expiry with refresh skew, and uses a fallback TTL for opaque tokens. - -If you use an async exporter resolver, `_Agent365Exporter` runs it with -`asyncio.run` on the thread that performs the export: - -- Scheduled batch exports and the final export during `shutdown()` run on the - BatchSpanProcessor worker thread. `shutdown()` still blocks its caller until - that export finishes. -- `force_flush()` exports on the calling thread. If you call it from inside a - running event loop, the async resolver can't be awaited and that export - fails with a logged error. - -Create async clients inside the resolver; don't reuse `aiohttp` or -`azure.identity.aio` clients bound to your app's event loop. From async code, -call `force_flush()` and `shutdown()` with `await asyncio.to_thread(...)`. A -synchronous, thread-safe cached resolver avoids these event-loop restrictions -and is the pattern the Agent365-Samples Python samples use. -Call `refresh_observability_token` only from the exporter's `token_resolver`; -its per-key locks are `asyncio.Lock`s, so do not also refresh the same cache -instance from your app's own event loop or threads. - -The previous delegated registration shape using `TurnContext` and -`Authorization.exchange_token` is removed for OBS export. `register_observability(...)` -is accepted only as a no-op compatibility shape: the cache logs once, stores no -token, and never calls `exchange_token`. - -Token resolvers should validate returned tokens before caching: reject any `scp` -claim and any `idtyp` other than `app`; when `idtyp` is absent, accept only a -non-empty `roles` array or a non-empty `oid` equal to `sub`; also verify `aud` -is the OBS resource (`9b975845-388f-4429-889e-eab1ef63949c`) and the token is -not expired. diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py deleted file mode 100644 index 6b41ec88..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Microsoft Agent 365 Observability Hosting Library. -""" - -from .middleware.baggage_middleware import BaggageMiddleware -from .middleware.observability_hosting_manager import ( - ObservabilityHostingManager, - ObservabilityHostingOptions, -) -from .middleware.output_logging_middleware import ( - A365_PARENT_TRACEPARENT_KEY, - OutputLoggingMiddleware, -) - -__all__ = [ - "BaggageMiddleware", - "OutputLoggingMiddleware", - "A365_PARENT_TRACEPARENT_KEY", - "ObservabilityHostingManager", - "ObservabilityHostingOptions", -] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py deleted file mode 100644 index 7459b245..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from .baggage_middleware import BaggageMiddleware -from .observability_hosting_manager import ObservabilityHostingManager, ObservabilityHostingOptions -from .output_logging_middleware import A365_PARENT_TRACEPARENT_KEY, OutputLoggingMiddleware - -__all__ = [ - "BaggageMiddleware", - "OutputLoggingMiddleware", - "A365_PARENT_TRACEPARENT_KEY", - "ObservabilityHostingManager", - "ObservabilityHostingOptions", -] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py deleted file mode 100644 index f1ee17cb..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/baggage_middleware.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Middleware that propagates OpenTelemetry baggage context derived from TurnContext.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable - -from microsoft_agents.activity import ActivityEventNames, ActivityTypes -from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder - -from ..scope_helpers.populate_baggage import populate - - -class BaggageMiddleware: - """Middleware that propagates OpenTelemetry baggage context derived from TurnContext. - - Async replies (ContinueConversation) are passed through without baggage setup. - """ - - async def on_turn( - self, - context: TurnContext, - logic: Callable[[TurnContext], Awaitable], - ) -> None: - activity = context.activity - is_async_reply = ( - activity is not None - and activity.type == ActivityTypes.event - and activity.name == ActivityEventNames.continue_conversation - ) - - if is_async_reply: - await logic() - return - - builder = BaggageBuilder() - populate(builder, context) - baggage_scope = builder.build() - - with baggage_scope: - await logic() diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py deleted file mode 100644 index 4a886660..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/observability_hosting_manager.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Singleton manager for configuring hosting-layer observability middleware.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass - -from microsoft_agents.hosting.core.middleware_set import MiddlewareSet - -from .baggage_middleware import BaggageMiddleware -from .output_logging_middleware import OutputLoggingMiddleware - -logger = logging.getLogger(__name__) - - -@dataclass -class ObservabilityHostingOptions: - """Configuration options for the hosting observability layer.""" - - enable_baggage: bool = False - """Enable baggage propagation middleware. Defaults to ``False``.""" - - enable_output_logging: bool = False - """Enable output logging middleware for tracing outgoing messages. Defaults to ``False``.""" - - -class ObservabilityHostingManager: - """Singleton manager for configuring hosting-layer observability middleware. - - Example: - .. code-block:: python - - ObservabilityHostingManager.configure(adapter.middleware_set, ObservabilityHostingOptions( - enable_output_logging=True, - )) - """ - - _instance: ObservabilityHostingManager | None = None - - def __init__(self) -> None: - """Private constructor — use :meth:`~microsoft_agents_a365.observability.hosting.middleware.observability_hosting_manager.ObservabilityHostingManager.configure` instead.""" - - @classmethod - def configure( - cls, - middleware_set: MiddlewareSet, - options: ObservabilityHostingOptions, - ) -> ObservabilityHostingManager: - """Configure the singleton instance and register middleware. - - Subsequent calls after the first are no-ops and return the existing instance. - - Args: - middleware_set: The middleware set to register middleware on - (e.g., ``adapter.middleware_set``). - options: Configuration options controlling which middleware to enable. - - Returns: - The singleton :class:`~microsoft_agents_a365.observability.hosting.middleware.observability_hosting_manager.ObservabilityHostingManager` instance. - - Raises: - TypeError: If *middleware_set* or *options* is ``None``. - """ - if middleware_set is None: - raise TypeError("middleware_set must not be None") - if options is None: - raise TypeError("options must not be None") - - if cls._instance is not None: - logger.warning( - "[ObservabilityHostingManager] Already configured. " - "Subsequent configure() calls are ignored." - ) - return cls._instance - - instance = cls() - - if options.enable_baggage: - middleware_set.use(BaggageMiddleware()) - logger.info("[ObservabilityHostingManager] BaggageMiddleware registered.") - - if options.enable_output_logging: - middleware_set.use(OutputLoggingMiddleware()) - logger.info("[ObservabilityHostingManager] OutputLoggingMiddleware registered.") - - logger.info( - "[ObservabilityHostingManager] Configured. Baggage: %s, OutputLogging: %s.", - options.enable_baggage, - options.enable_output_logging, - ) - - cls._instance = instance - return instance - - @classmethod - def _reset(cls) -> None: - """Reset the singleton instance. Intended for testing only.""" - cls._instance = None diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py deleted file mode 100644 index e0ec701a..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py +++ /dev/null @@ -1,187 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Middleware that creates OutputScope spans for outgoing messages.""" - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable - -from microsoft_agents.activity import Activity -from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents_a365.observability.core.agent_details import AgentDetails -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, -) -from microsoft_agents_a365.observability.core.models.response import Response -from microsoft_agents_a365.observability.core.models.user_details import UserDetails -from microsoft_agents_a365.observability.core.request import Request -from microsoft_agents_a365.observability.core.span_details import SpanDetails -from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope -from microsoft_agents_a365.observability.core.utils import extract_context_from_headers - -logger = logging.getLogger(__name__) - -# TurnState key for the parent trace context (W3C traceparent string). -A365_PARENT_TRACEPARENT_KEY = "A365ParentTraceparent" - - -def _derive_agent_details(context: TurnContext) -> AgentDetails | None: - """Derive target agent details from the activity recipient. - - Returns ``None`` when the activity is not an agentic request or the - recipient is missing, so callers can short-circuit without emitting - spans with empty identifiers. - """ - activity = context.activity - if not activity.is_agentic_request(): - return None - recipient = getattr(activity, "recipient", None) - if not recipient: - return None - return AgentDetails( - agent_id=activity.get_agentic_instance_id() or "", - agent_name=getattr(recipient, "name", None), - agentic_user_id=getattr(recipient, "aad_object_id", None), - agentic_user_email=activity.get_agentic_user(), - agent_description=getattr(recipient, "role", None), - tenant_id=getattr(recipient, "tenant_id", None), - ) - - -def _derive_user_details(context: TurnContext) -> UserDetails | None: - """Derive user identity details from the activity from property.""" - frm = getattr(context.activity, "from_property", None) - if not frm: - return None - return UserDetails( - user_id=getattr(frm, "aad_object_id", None), - user_name=getattr(frm, "name", None), - ) - - -def _derive_conversation_id(context: TurnContext) -> str | None: - """Derive conversation id from the TurnContext.""" - conv = getattr(context.activity, "conversation", None) - return conv.id if conv else None - - -def _derive_channel( - context: TurnContext, -) -> dict[str, str | None]: - """Derive channel (name and link) from TurnContext.""" - channel_id = getattr(context.activity, "channel_id", None) - channel_name: str | None = None - sub_channel: str | None = None - if channel_id is not None: - if isinstance(channel_id, str): - channel_name = channel_id - elif hasattr(channel_id, "channel"): - channel_name = channel_id.channel - sub_channel = channel_id.sub_channel - return {"name": channel_name, "link": sub_channel} - - -class OutputLoggingMiddleware: - """Middleware that creates :class:`microsoft_agents_a365.observability.core.spans_scopes.output_scope.OutputScope` spans for outgoing messages. - - Links to a parent span when :data:`~microsoft_agents_a365.observability.hosting.middleware.output_logging_middleware.A365_PARENT_TRACEPARENT_KEY` is set in - ``turn_state``. - - **Privacy note:** Outgoing message content is captured verbatim as span - attributes and exported to the configured telemetry backend. - """ - - async def on_turn( - self, - context: TurnContext, - logic: Callable[[TurnContext], Awaitable], - ) -> None: - agent_details = _derive_agent_details(context) - - if not agent_details: - await logic() - return - - user_details = _derive_user_details(context) - conversation_id = _derive_conversation_id(context) - channel = _derive_channel(context) - - context.on_send_activities( - self._create_send_handler( - context, - agent_details, - user_details, - conversation_id, - channel, - ) - ) - - await logic() - - def _create_send_handler( - self, - turn_context: TurnContext, - agent_details: AgentDetails, - user_details: UserDetails | None, - conversation_id: str | None, - channel: dict[str, str | None], - ) -> Callable: - """Create a send handler that wraps outgoing messages in OutputScope spans. - - Reads parent span ref lazily so the agent handler can set it during ``logic()``. - """ - - async def handler( - ctx: TurnContext, - activities: list[Activity], - send_next: Callable, - ) -> None: - messages = [ - a.text for a in activities if getattr(a, "type", None) == "message" and a.text - ] - - if not messages: - await send_next() - return - - traceparent: str | None = turn_context.turn_state.get(A365_PARENT_TRACEPARENT_KEY) - parent_context = None - if traceparent: - parent_context = extract_context_from_headers({"traceparent": traceparent}) - else: - logger.warning( - "[OutputLoggingMiddleware] No traceparent in turn_state under " - "'%s'. OutputScope will not be linked to a parent.", - A365_PARENT_TRACEPARENT_KEY, - ) - - request = Request( - conversation_id=conversation_id, - ) - - span_details = SpanDetails(parent_context=parent_context) if parent_context else None - - output_scope = OutputScope.start( - request=request, - response=Response(messages=messages), - agent_details=agent_details, - user_details=user_details, - span_details=span_details, - ) - - # Set additional attributes on the scope - output_scope.set_tag_maybe(CHANNEL_NAME_KEY, channel.get("name")) - output_scope.set_tag_maybe(CHANNEL_LINK_KEY, channel.get("link")) - - try: - await send_next() - except Exception as error: - output_scope.record_error(error) - raise - finally: - output_scope.dispose() - - return handler diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py deleted file mode 100644 index f54d39b3..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_baggage.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -from collections.abc import Iterator -from typing import Any - -from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder - -from .utils import ( - get_caller_pairs, - get_channel_pairs, - get_conversation_pairs, - get_target_agent_pairs, - get_tenant_id_pair, -) - - -def _iter_all_pairs(turn_context: TurnContext) -> Iterator[tuple[str, Any]]: - activity = turn_context.activity - if not activity: - return - yield from get_caller_pairs(activity) - yield from get_target_agent_pairs(activity) - yield from get_tenant_id_pair(activity) - yield from get_channel_pairs(activity) - yield from get_conversation_pairs(activity) - - -def populate(builder: BaggageBuilder, turn_context: TurnContext) -> BaggageBuilder: - """Populate BaggageBuilder with baggage values extracted from a turn context. - - Args: - builder: The BaggageBuilder instance to populate - turn_context: The TurnContext containing activity information - - Returns: - The updated BaggageBuilder instance (for method chaining) - """ - builder.set_pairs(_iter_all_pairs(turn_context)) - return builder diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py deleted file mode 100644 index cea776d7..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/populate_invoke_agent_scope.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope - -from .utils import ( - get_caller_pairs, - get_channel_pairs, - get_conversation_pairs, - get_target_agent_pairs, - get_tenant_id_pair, -) - -if TYPE_CHECKING: - from microsoft_agents.hosting.core.turn_context import TurnContext - - -def populate(scope: InvokeAgentScope, turn_context: TurnContext) -> InvokeAgentScope: - """ - Populate all supported InvokeAgentScope tags from the provided TurnContext. - :param scope: The InvokeAgentScope instance to populate. - :param turn_context: The TurnContext containing activity information. - :return: The updated InvokeAgentScope instance. - """ - if not turn_context: - raise ValueError("turn_context is required") - - if not turn_context.activity: - return scope - - activity = turn_context.activity - - scope.record_attributes(get_caller_pairs(activity)) - scope.record_attributes(get_target_agent_pairs(activity)) - scope.record_attributes(get_tenant_id_pair(activity)) - scope.record_attributes(get_channel_pairs(activity)) - scope.record_attributes(get_conversation_pairs(activity)) - - if activity.text: - scope.record_input_messages([activity.text]) - - return scope diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py deleted file mode 100644 index 34b62a8e..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/scope_helpers/utils.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -import json -from collections.abc import Iterator -from typing import Any - -from microsoft_agents.activity import Activity -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_DESCRIPTION_KEY, - GEN_AI_AGENT_EMAIL_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_CONVERSATION_ITEM_LINK_KEY, - TENANT_ID_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) - -AGENT_ROLE = "agenticUser" - - -def _is_agentic(entity: Any) -> bool: - if not entity: - return False - return bool( - entity.agentic_user_id - or ((role := entity.role) and isinstance(role, str) and role.lower() == AGENT_ROLE.lower()) - ) - - -def get_caller_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: - frm = activity.from_property - if not frm: - return - yield USER_ID_KEY, frm.aad_object_id - yield USER_NAME_KEY, frm.name - yield USER_EMAIL_KEY, frm.agentic_user_id - - -def get_target_agent_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: - rec = activity.recipient - if not rec: - return - yield GEN_AI_AGENT_ID_KEY, activity.get_agentic_instance_id() - yield GEN_AI_AGENT_NAME_KEY, rec.name - yield GEN_AI_AGENT_AUID_KEY, rec.aad_object_id - yield GEN_AI_AGENT_EMAIL_KEY, activity.get_agentic_user() - yield ( - GEN_AI_AGENT_DESCRIPTION_KEY, - rec.role, - ) - - -def get_tenant_id_pair(activity: Activity) -> Iterator[tuple[str, Any]]: - yield TENANT_ID_KEY, activity.recipient.tenant_id - - -def get_channel_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: - """ - Generate channel pairs from activity, handling both string and ChannelId object cases. - - :param activity: The activity object (Activity instance or dict) - :return: Iterator of (key, value) tuples for channel information - """ - # Handle channel_id (can be string or ChannelId object) - channel_id = activity.channel_id - - # Extract channel name from either string or ChannelId object - channel_name = None - sub_channel = None - - if channel_id is not None: - # Check for ChannelId object first - if hasattr(channel_id, "channel"): - # ChannelId object - channel_name = channel_id.channel - sub_channel = channel_id.sub_channel - elif isinstance(channel_id, str): - # Direct string value - channel_name = channel_id - - # Try to get sub_channel from productContext in channel_data if sub_channel is not set - if not sub_channel and activity.channel_data: - try: - # Convert channel_data to dict if it's a string - if isinstance(activity.channel_data, str): - channel_data_dict = json.loads(activity.channel_data) - elif isinstance(activity.channel_data, dict): - channel_data_dict = activity.channel_data - else: - # Try to convert to dict if it has __dict__ - channel_data_dict = getattr(activity.channel_data, "__dict__", {}) - - # Extract productContext if available - if isinstance(channel_data_dict, dict) and "productContext" in channel_data_dict: - product_context = channel_data_dict["productContext"] - if isinstance(product_context, str): - sub_channel = product_context - except (json.JSONDecodeError, AttributeError, TypeError): - # Silently ignore any parsing errors - pass - - # Yield channel name as source name - yield CHANNEL_NAME_KEY, channel_name - yield CHANNEL_LINK_KEY, sub_channel - - -def get_conversation_pairs(activity: Activity) -> Iterator[tuple[str, Any]]: - conv = activity.conversation - conversation_id = conv.id if conv else None - - item_link = activity.service_url - - yield GEN_AI_CONVERSATION_ID_KEY, conversation_id - yield GEN_AI_CONVERSATION_ITEM_LINK_KEY, item_link diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py deleted file mode 100644 index ea1f5850..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -"""Token cache helpers for observability.""" - -from .agent_token_cache import AgenticTokenCache, AgenticTokenStruct, ObservabilityTokenResolver - -__all__ = ["AgenticTokenCache", "AgenticTokenStruct", "ObservabilityTokenResolver"] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py deleted file mode 100644 index d317f304..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/token_cache_helpers/agent_token_cache.py +++ /dev/null @@ -1,312 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""App-only token cache for Agent 365 observability export.""" - -from __future__ import annotations - -import asyncio -import base64 -import json -import logging -import time -from collections.abc import Awaitable, Callable, Sequence -from dataclasses import dataclass, field -from inspect import isawaitable -from threading import Lock - -from microsoft_agents.hosting.core.app.oauth.authorization import Authorization -from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents_a365.runtime.environment_utils import get_observability_authentication_scope - -logger = logging.getLogger(__name__) - -ObservabilityTokenResolver = Callable[[str, str, list[str]], str | Awaitable[str | None] | None] - - -@dataclass -class AgenticTokenStruct: - """Deprecated delegated OBS token generator shape. - - OBS export is S2S-only. Instances of this type are accepted only for source - compatibility; the cache never calls ``authorization.exchange_token``. - """ - - authorization: Authorization - """The user authorization object from the removed delegated OBS flow.""" - - turn_context: TurnContext - """The turn context from the removed delegated OBS flow.""" - - auth_handler_name: str | None = "AGENTIC" - """The name of the removed delegated authentication handler.""" - - -class AgenticTokenCache: - """Caches app-only OBS tokens per ``(agent_id, tenant_id)``. - - Call :meth:`refresh_observability_token` from the exporter's token resolver - to acquire or refresh an app-only token. The resolver receives the exporting - agent ID, tenant ID, and OBS ``/.default`` scopes. Delegated TurnContext / - Authorization shapes are logged once and ignored. - """ - - @dataclass - class _Entry: - """Internal entry structure for cache storage.""" - - scopes: tuple[str, ...] - token: str | None = None - expires_on_ms: float | None = None - acquired_on_ms: float | None = None - lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False, compare=False) - - _default_refresh_skew_ms = 60_000 - _default_max_token_age_ms = 3_600_000 - _max_exp_seconds = 86_400 - _max_cache_size = 10_000 - - def __init__(self, observability_scopes: Sequence[str] | None = None) -> None: - """Initialize the token cache.""" - if isinstance(observability_scopes, str): - observability_scopes = (observability_scopes,) - self._map: dict[tuple[str, str], AgenticTokenCache._Entry] = {} - self._lock = Lock() - self._observability_scopes = ( - None if observability_scopes is None else tuple(observability_scopes) - ) - self._removed_registration_logged = False - - @staticmethod - def _make_key(agent_id: str, tenant_id: str) -> tuple[str, str]: - # A tuple key keeps identities apart even when an ID contains a separator character. - return (agent_id, tenant_id) - - def register_observability( - self, - agent_id: str, - tenant_id: str, - token_generator: AgenticTokenStruct, - observability_scopes: list[str], - ) -> None: - """Deprecated no-op for the removed delegated OBS registration flow.""" - self._log_removed_registration_once() - - async def refresh_observability_token( - self, - agent_id: str, - tenant_id: str, - token_resolver: ObservabilityTokenResolver, - ) -> str: - """Refresh an app-only OBS token for the exporting agent identity. - - Args: - agent_id: The exporting agent instance identifier. - tenant_id: The exporting tenant identifier. - token_resolver: App-only token resolver receiving ``(agent_id, - tenant_id, scopes)``. - - Returns: - The cached app-only OBS token. - - Raises: - TypeError: If ``token_resolver`` is not callable. - ValueError: If agent or tenant IDs are empty, or no scopes are configured. - Exception: Propagates resolver failures after retry handling. - """ - if not callable(token_resolver): - raise TypeError("token_resolver must be callable") - - if not agent_id or not agent_id.strip() or not tenant_id or not tenant_id.strip(): - raise ValueError("[AgenticTokenCache] Agent and tenant IDs are required") - - key = self._make_key(agent_id, tenant_id) - entry = self._get_or_create_entry(key) - async with entry.lock: - token = entry.token - if token is not None and not self._is_expired(entry): - return token - - return await self._acquire_token(agent_id, tenant_id, entry, token_resolver) - - async def get_observability_token(self, agent_id: str, tenant_id: str) -> str | None: - """Return a non-expired cached app-only OBS token, or ``None``. - - This method is a pure cache read. It never acquires a token and never - calls delegated token exchange. - """ - key = self._make_key(agent_id, tenant_id) - with self._lock: - entry = self._map.get(key) - - if entry is None or entry.token is None: - logger.debug("[AgenticTokenCache] No token cached for %s", key) - return None - if self._is_expired(entry): - logger.debug("[AgenticTokenCache] Token expired for %s", key) - return None - return entry.token - - def invalidate_token(self, agent_id: str, tenant_id: str) -> None: - """Invalidate one cached token. - - The entry is removed, so a refresh already in flight for this identity - cannot repopulate the cache. - """ - key = self._make_key(agent_id, tenant_id) - with self._lock: - entry = self._map.pop(key, None) - if entry is not None: - self._clear_token(entry) - - def invalidate_all(self) -> None: - """Invalidate all cached tokens.""" - with self._lock: - self._map.clear() - - def _get_or_create_entry(self, key: tuple[str, str]) -> _Entry: - with self._lock: - entry = self._map.get(key) - if entry is not None: - if not entry.scopes: - raise ValueError("[AgenticTokenCache] Entry has invalid scopes") - return entry - - scopes = self._get_effective_scopes() - if not scopes: - raise ValueError("[AgenticTokenCache] No valid scopes") - - # Evict the oldest idle entries until there is room. Entries with a refresh in flight keep - # their lock, so the cache can briefly exceed its bound; the next insertion trims it back. - while len(self._map) >= self._max_cache_size: - idle_key = next( - ( - existing_key - for existing_key, existing in self._map.items() - if not existing.lock.locked() - ), - None, - ) - if idle_key is None: - break - del self._map[idle_key] - - entry = AgenticTokenCache._Entry(scopes=scopes) - self._map[key] = entry - return entry - - def _get_effective_scopes(self) -> tuple[str, ...]: - scopes = self._observability_scopes - if scopes is None: - scopes = tuple(get_observability_authentication_scope()) - return tuple(scope for scope in scopes if scope and scope.strip()) - - async def _acquire_token( - self, - agent_id: str, - tenant_id: str, - entry: _Entry, - resolver: ObservabilityTokenResolver, - ) -> str: - max_retries = 2 - last_error: BaseException | None = None - for attempt in range(max_retries + 1): - logger.info( - "[AgenticTokenCache] Acquiring app-only token attempt %s/%s", - attempt + 1, - max_retries + 1, - ) - try: - result = resolver(agent_id, tenant_id, list(entry.scopes)) - token = await result if isawaitable(result) else result - if token is None or not token.strip(): - raise RuntimeError( - "[AgenticTokenCache] App-only token resolver returned no token" - ) - entry.token = token - entry.acquired_on_ms = time.time() * 1000 - exp = self._decode_exp(token) - if exp is not None: - entry.expires_on_ms = exp * 1000 - else: - entry.expires_on_ms = None - logger.warning("[AgenticTokenCache] No exp claim, fallback TTL") - logger.info("[AgenticTokenCache] Token cached") - return token - except Exception as error: - last_error = error - if self._is_retriable_error(error) and attempt < max_retries: - logger.warning( - "[AgenticTokenCache] Retriable token acquisition failure attempt %s: %s", - attempt + 1, - error, - ) - await asyncio.sleep(0.2 * (attempt + 1)) - continue - logger.error("[AgenticTokenCache] Token acquisition failed: %s", error) - self._clear_token(entry) - raise - - self._clear_token(entry) - raise RuntimeError("[AgenticTokenCache] Token acquisition failed") from last_error - - def _decode_exp(self, jwt: str) -> int | None: - try: - parts = jwt.split(".") - if len(parts) < 2: - return None - payload = parts[1] - padded = payload + "=" * ((4 - (len(payload) % 4)) % 4) - decoded = base64.urlsafe_b64decode(padded.encode("utf-8")) - claims = json.loads(decoded.decode("utf-8")) - if not isinstance(claims, dict): - return None - exp = claims.get("exp") - if not isinstance(exp, int | float): - return None - max_exp = int(time.time()) + self._max_exp_seconds - return min(int(exp), max_exp) - except Exception: - return None - - def _is_expired(self, entry: _Entry) -> bool: - now = time.time() * 1000 - if entry.expires_on_ms is not None: - return now >= entry.expires_on_ms - self._default_refresh_skew_ms - if entry.acquired_on_ms is not None: - return now >= entry.acquired_on_ms + self._default_max_token_age_ms - return True - - def _is_retriable_error(self, error: Exception) -> bool: - if isinstance(error, (TimeoutError, ConnectionError)): - return True - - message = str(error).lower() - if "timeout" in message or "econnreset" in message or "network" in message: - return True - - status = self._get_status(error) - return status in (408, 429) or (status is not None and 500 <= status < 600) - - def _get_status(self, error: Exception) -> int | None: - for name in ("status", "status_code"): - value = getattr(error, name, None) - if isinstance(value, int): - return value - return None - - def _clear_token(self, entry: _Entry) -> None: - entry.token = None - entry.expires_on_ms = None - entry.acquired_on_ms = None - - def _log_removed_registration_once(self) -> None: - with self._lock: - if self._removed_registration_logged: - return - self._removed_registration_logged = True - logger.error( - "[AgenticTokenCache] Delegated OBS token registration was removed and does " - "nothing; S2S OBS needs an app-only token. Call " - "refresh_observability_token(agent_id, tenant_id, token_resolver) instead." - ) diff --git a/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml b/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml deleted file mode 100644 index df25b8a6..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] -build-backend = "build_backend" - -[project] -name = "microsoft-agents-a365-observability-hosting" -dynamic = ["version"] -authors = [ - { name = "Microsoft", email = "support@microsoft.com" }, -] -description = "Hosting components for Agent 365 observability" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", -] -license = {text = "MIT"} -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents", "hosting"] -dependencies = [ - "microsoft-agents-hosting-core", - "microsoft-agents-a365-observability-core", - "microsoft-agents-a365-runtime", - "opentelemetry-api", -] - -[project.urls] -Homepage = "https://github.com/microsoft/Agent365-python" -Repository = "https://github.com/microsoft/Agent365-python" -Issues = "https://github.com/microsoft/Agent365-python/issues" -Documentation = "https://github.com/microsoft/Agent365-python/tree/main/libraries/microsoft-agents-a365-observability-hosting" - -[tool.setuptools.packages.find] -include = ["microsoft_agents_a365*"] -namespaces = true diff --git a/libraries/microsoft-agents-a365-observability-hosting/setup.py b/libraries/microsoft-agents-a365-observability-hosting/setup.py deleted file mode 100644 index 8fa3ad8d..00000000 --- a/libraries/microsoft-agents-a365-observability-hosting/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import sys -from os import environ -from pathlib import Path - -from setuptools import setup - -# Get version from environment variable set by CI/CD -package_version = environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") - -# Add versioning helper to path -helper_path = Path(__file__).parent.parent.parent / "versioning" / "helper" -sys.path.insert(0, str(helper_path)) - -from setup_utils import get_dynamic_dependencies # noqa: E402 - -# Use exact version matching for internal dependencies: -# - Internal packages get: == current_version (e.g., == 1.2.3) -# - Ensures all SDK packages must be at the same version -# - Prevents incompatibility issues from version mismatches -setup( - version=package_version, - install_requires=get_dynamic_dependencies( - use_exact_match=True, - ), -) diff --git a/libraries/microsoft-agents-a365-runtime/CHANGELOG.md b/libraries/microsoft-agents-a365-runtime/CHANGELOG.md index 3c7f41af..f7e8ab6a 100644 --- a/libraries/microsoft-agents-a365-runtime/CHANGELOG.md +++ b/libraries/microsoft-agents-a365-runtime/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this package will be documented in this file. +## [Unreleased] + +### Breaking Changes + +- Remove `get_observability_authentication_scope()` from + `microsoft_agents_a365.runtime` and remove `PROD_OBSERVABILITY_SCOPE` and + `PROD_OBSERVABILITY_CLUSTER_CATEGORY` from `runtime.environment_utils`. + Observability has moved to `microsoft-opentelemetry`; follow the + [migration guide](../../docs/observability-migration.md), including its + route-specific authentication guidance. Generic Runtime utilities remain + supported. The shared workspace release target is now `3.0.0`. + ## [2.0.0] - 2026-10-20 ### Breaking Changes diff --git a/libraries/microsoft-agents-a365-runtime/README.md b/libraries/microsoft-agents-a365-runtime/README.md index 4c065707..7dd2204c 100644 --- a/libraries/microsoft-agents-a365-runtime/README.md +++ b/libraries/microsoft-agents-a365-runtime/README.md @@ -3,7 +3,9 @@ [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-a365-runtime?label=PyPI&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-runtime) [![PyPI Downloads](https://img.shields.io/pypi/dm/microsoft-agents-a365-runtime?label=Downloads&logo=pypi)](https://pypi.org/project/microsoft-agents-a365-runtime) -Core runtime utilities and environment management for AI agent applications. This package provides essential Power Platform API discovery, environment configuration, and authentication scope resolution. +Core runtime utilities and environment management for AI agent applications. This package provides Power Platform API discovery, environment configuration, JWT and agent identity utilities, and operation results. + +The observability-specific authentication scope helper has been removed. See the [Observability migration guide](https://github.com/microsoft/Agent365-python/blob/main/docs/observability-migration.md) for the separately maintained Microsoft OpenTelemetry Distro. ## Installation diff --git a/libraries/microsoft-agents-a365-runtime/docs/design.md b/libraries/microsoft-agents-a365-runtime/docs/design.md index 50efd9f0..033ac106 100644 --- a/libraries/microsoft-agents-a365-runtime/docs/design.md +++ b/libraries/microsoft-agents-a365-runtime/docs/design.md @@ -45,7 +45,7 @@ discovery = PowerPlatformApiDiscovery("prod") endpoint = discovery.get_tenant_endpoint("tenant-id-123") # Returns: "tenantid12.3.tenant.api.powerplatform.com" -# Get island cluster endpoint (for observability) +# Get island cluster endpoint island_endpoint = discovery.get_tenant_island_cluster_endpoint("tenant-id-123") # Returns: "il-tenantid12.3.tenant.api.powerplatform.com" @@ -163,17 +163,16 @@ print(error.exception) # ValueError instance Environment-specific configuration utilities. ```python -from microsoft_agents_a365.runtime import get_observability_authentication_scope +from microsoft_agents_a365.runtime.environment_utils import is_development_environment -# Get authentication scope for observability -scope = get_observability_authentication_scope() -# Returns ["api://9b975845-388f-4429-889e-eab1ef63949c/.default"] +development = is_development_environment() +# Checks PYTHON_ENVIRONMENT; defaults to production. ``` -Observability export is S2S-only. This helper returns the OBS resource -`/.default` scope for app-only token acquisition by the exporting agent identity; -it must not be used to acquire delegated `Agent365.Observability.OtelWrite` -tokens for OBS export. +The observability-only scope constants and `get_observability_authentication_scope()` +were removed with the retired components. Generic environment detection and +Power Platform endpoint discovery remain supported. See the +[migration guide](../../../docs/observability-migration.md). ## Type Definitions @@ -277,6 +276,5 @@ pytest tests/runtime/test_operation_result.py -v The runtime package is used by: -- **observability-core**: `PowerPlatformApiDiscovery` for exporter endpoint resolution - **tooling**: `Utility` for agent identity resolution and User-Agent generation - **tooling**: `OperationResult` for `send_chat_history` return type diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py index b27952f3..90f8e551 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py @@ -1,14 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from .environment_utils import get_observability_authentication_scope from .operation_error import OperationError from .operation_result import OperationResult from .power_platform_api_discovery import ClusterCategory, PowerPlatformApiDiscovery from .utility import Utility __all__ = [ - "get_observability_authentication_scope", "PowerPlatformApiDiscovery", "ClusterCategory", "Utility", diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py index 5bab292f..ef51f59a 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/environment_utils.py @@ -7,36 +7,11 @@ import os -# Authentication scopes for different environments. -# -# Agent 365 observability export uses the app-only S2S route. Token resolvers must -# request the OBS resource's /.default scope for the exporting agent identity; they -# must not acquire delegated Agent365.Observability.OtelWrite tokens for OBS export. -PROD_OBSERVABILITY_SCOPE = "api://9b975845-388f-4429-889e-eab1ef63949c/.default" - -# Cluster categories for different environments -PROD_OBSERVABILITY_CLUSTER_CATEGORY = "prod" - # Default environment names PRODUCTION_ENVIRONMENT_NAME = "production" DEVELOPMENT_ENVIRONMENT_NAME = "Development" -def get_observability_authentication_scope() -> list[str]: - """ - Returns the scope for authenticating to the observability service based on the current environment. - - The scope can be overridden via the A365_OBSERVABILITY_SCOPE_OVERRIDE environment variable - to enable testing against pre-production environments. The default scope is the OBS - resource ``/.default`` scope for app-only S2S tokens. - - Returns: - list[str]: The authentication scope for the current environment. - """ - override_scope = os.getenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", "").strip() - return [override_scope] if override_scope else [PROD_OBSERVABILITY_SCOPE] - - def is_development_environment() -> bool: """ Returns True if the current environment is a development environment. diff --git a/libraries/microsoft-agents-a365-runtime/pyproject.toml b/libraries/microsoft-agents-a365-runtime/pyproject.toml index ebe7722b..7933dc5e 100644 --- a/libraries/microsoft-agents-a365-runtime/pyproject.toml +++ b/libraries/microsoft-agents-a365-runtime/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] authors = [ { name = "Microsoft", email = "support@microsoft.com" }, ] -description = "Telemetry, tracing, and monitoring components for AI agents" +description = "Runtime utilities, environment configuration, and Power Platform API discovery for AI agents" readme = "README.md" requires-python = ">=3.11" classifiers = [ @@ -20,10 +20,9 @@ classifiers = [ "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: System :: Monitoring", ] license = {text = "MIT"} -keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents"] +keywords = ["runtime", "power-platform", "ai", "agents"] dependencies = [ "PyJWT", ] diff --git a/libraries/microsoft-agents-a365-tooling/pyproject.toml b/libraries/microsoft-agents-a365-tooling/pyproject.toml index be0f7197..49cdc50e 100644 --- a/libraries/microsoft-agents-a365-tooling/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ ] license = {text = "MIT"} dependencies = [ + "microsoft-agents-a365-runtime", "pydantic", "typing-extensions", "microsoft-agents-hosting-core", diff --git a/pyproject.toml b/pyproject.toml index 4fdcdc79..a9ed4bfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,6 @@ build-backend = "setuptools.build_meta" [tool.uv.workspace] members = [ "libraries/microsoft-agents-a365-notifications", - "libraries/microsoft-agents-a365-observability-core", - "libraries/microsoft-agents-a365-observability-extensions-langchain", - "libraries/microsoft-agents-a365-observability-extensions-openai", - "libraries/microsoft-agents-a365-observability-extensions-semantickernel", - "libraries/microsoft-agents-a365-observability-extensions-agentframework", "libraries/microsoft-agents-a365-runtime", "libraries/microsoft-agents-a365-tooling", "libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry", @@ -17,7 +12,6 @@ members = [ "libraries/microsoft-agents-a365-tooling-extensions-semantickernel", "libraries/microsoft-agents-a365-tooling-extensions-agentframework", "libraries/microsoft-agents-a365-tooling-extensions-googleadk", - "libraries/microsoft-agents-a365-observability-hosting", ] exclude = [ ".", @@ -27,12 +21,6 @@ exclude = [ [tool.uv.sources] microsoft-agents-a365-notifications = { workspace = true } -microsoft-agents-a365-observability-core = { workspace = true } -microsoft-agents-a365-observability-extensions-agentframework = { workspace = true } -microsoft-agents-a365-observability-extensions-langchain = { workspace = true } -microsoft-agents-a365-observability-extensions-openai = { workspace = true } -microsoft-agents-a365-observability-extensions-semantickernel = { workspace = true } -microsoft-agents-a365-observability-hosting = { workspace = true } microsoft-agents-a365-runtime = { workspace = true } microsoft-agents-a365-tooling = { workspace = true } microsoft-agents-a365-tooling-extensions-agentframework = { workspace = true } @@ -47,17 +35,18 @@ dev-dependencies = [ "pytest", "pytest-asyncio", "pytest-mock", + "pytest-cov", "ruff", "tomlkit", "setuptools", "tox>=4.0", "tox-uv>=1.0", + "twine", "python-dotenv", "openai", "agent-framework", "azure-identity", "openai-agents", - "langchain-openai", ] # azure-ai-projects is capped at <2.0.0 because: @@ -74,25 +63,18 @@ override-dependencies = [ # Security-critical transitive dependencies must also be declared by affected libraries # so the build backend includes their minimum versions in published wheel metadata. constraint-dependencies = [ - # --- Observability (OpenTelemetry) --- + # --- OpenTelemetry (transitive dependencies of retained AI frameworks) --- "opentelemetry-api >= 1.36.0", "opentelemetry-sdk >= 1.36.0", - "opentelemetry-exporter-otlp >= 1.36.0", - "opentelemetry-exporter-otlp-proto-grpc >= 1.36.0", - "opentelemetry-instrumentation >= 0.47b0", # --- Azure Services --- "azure-ai-agents >= 1.0.0b251001", "azure-ai-projects>=1.0.0b12,<2.0.0", "azure-identity >= 1.12.0", - "azure-monitor-ingestion >= 1.0.0", - "azure-monitor-opentelemetry-exporter >= 1.0.0b39", # --- AI Frameworks --- "agent-framework >= 1.0.0", "agent-framework-core >= 1.0.0", - "langchain-core >= 1.3.3", - "langsmith >= 0.8.18", "mcp >= 1.28.1", "openai-agents >= 0.2.6", "semantic-kernel >= 1.39.3", @@ -110,9 +92,6 @@ constraint-dependencies = [ "microsoft-agents-activity >= 0.4.0", "microsoft-agents-hosting-core >= 0.4.0", - # --- Monkey-patching / wrapping --- - "wrapt >= 1.14", - # --- Data Validation & Utilities --- "aiohttp >= 3.8.0", "anyio >= 4.14.2", @@ -138,6 +117,7 @@ constraint-dependencies = [ "pytest-mock >= 3.10.0", "python-dotenv >= 1.0.0", "ruff >= 0.1.0", + "twine == 6.2.0", ] # Test configuration @@ -257,11 +237,6 @@ commands = [["ruff", "check", ".", "--preview", { replace = "posargs", default = base_python = ["python3.11"] commands = [["ruff", "format", "--check", ".", { replace = "posargs", default = "", extend = true }]] -[tool.tox.env.integration] -base_python = ["python3.11"] -commands = [["pytest", "-m", "integration", "-v", "--tb=short", { replace = "posargs", default = "", extend = true }]] -pass_env = ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT", "AZURE_OPENAI_API_VERSION", "AGENT365_TEST_TENANT_ID", "AGENT365_TEST_AGENT_ID", "ENABLE_OBSERVABILITY"] - [tool.tox.env.verify-constraints] base_python = ["python3.11"] commands = [["python", "scripts/verify_constraints.py"]] diff --git a/scripts/verify_release_artifacts.py b/scripts/verify_release_artifacts.py new file mode 100644 index 00000000..ba7f9ea4 --- /dev/null +++ b/scripts/verify_release_artifacts.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Fail closed unless a wheel-only release contains exactly the active workspace packages.""" + +import argparse +import sys +import tomllib +from email.parser import BytesParser +from pathlib import Path +from zipfile import BadZipFile, ZipFile + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name, parse_wheel_filename +from packaging.version import Version + +ROOT = Path(__file__).resolve().parent.parent +RETIRED_PREFIX = "microsoft-agents-a365-observability" + + +def verify_release_artifacts(directory: Path, version: str) -> None: + """Reject missing, duplicate, unexpected, stale, or observability-bearing wheels.""" + with (ROOT / "pyproject.toml").open("rb") as file: + members = tomllib.load(file)["tool"]["uv"]["workspace"]["members"] + expected: set[str] = set() + for member in members: + with (ROOT / member / "pyproject.toml").open("rb") as file: + name = canonicalize_name(tomllib.load(file)["project"]["name"]) + if name.startswith(RETIRED_PREFIX): + raise ValueError(f"Retired distribution in workspace: {name}") + expected.add(name) + + seen: set[str] = set() + for path in sorted(directory.iterdir()): + # uv creates this sentinel in its output directory. + if path.name == ".gitignore" and path.is_file(): + continue + if path.suffix != ".whl" or not path.is_file(): + raise ValueError(f"Unexpected release artifact: {path.name}") + filename_name, filename_version, _, _ = parse_wheel_filename(path.name) + with ZipFile(path) as archive: + entries = archive.namelist() + if any("microsoft_agents_a365/observability/" in entry for entry in entries): + raise ValueError(f"Retired observability payload in {path.name}") + metadata_files = [entry for entry in entries if entry.endswith(".dist-info/METADATA")] + if len(metadata_files) != 1: + raise ValueError(f"Expected exactly one METADATA in {path.name}") + metadata = BytesParser().parsebytes(archive.read(metadata_files[0])) + name = canonicalize_name(metadata.get("Name", "")) + if name not in expected or name in seen: + raise ValueError(f"Unexpected or duplicate distribution: {name}") + if filename_name != name or filename_version != Version(metadata.get("Version", "")): + raise ValueError(f"Filename and metadata disagree: {path.name}") + if filename_version != Version(version): + raise ValueError(f"Stale version in {path.name}; expected {version}") + for value in metadata.get_all("Requires-Dist", []): + requirement = Requirement(value) + if canonicalize_name(requirement.name).startswith(RETIRED_PREFIX): + raise ValueError(f"{name} depends on retired distribution: {requirement}") + seen.add(name) + if seen != expected: + raise ValueError(f"Missing distributions: {', '.join(sorted(expected - seen))}") + print(f"Verified {len(seen)} active wheels at version {version}; no retired distributions.") + + +def main() -> int: + """Verify a fresh build directory before copying or uploading any release artifacts.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--version", required=True) + args = parser.parse_args() + try: + verify_release_artifacts(args.directory, args.version) + except (OSError, ValueError, BadZipFile, InvalidRequirement) as error: + print(f"Release artifact verification failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/.env.example b/tests/.env.example deleted file mode 100644 index 98970f58..00000000 --- a/tests/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Use this as an example .env file for your project - -FOUNDRY_PROJECT_ENDPOINT={required} -FOUNDRY_AGENT_ID={optional} - -AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE=true -APPLICATIONINSIGHTS_CONNECTION_STRING={optional} -AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED=true -OPENAI_AGENTS_DISABLE_TRACING=false -OTEL_SDK_DISABLED=false - -AZURE_OPENAI_API_VERSION={optional} -AZURE_OPENAI_ENDPOINT={optional} -AZURE_OPENAI_DEPLOYMENT={optional} -AZURE_OPENAI_API_KEY={required_for_integration_tests} diff --git a/tests/README.md b/tests/README.md index 5a420cba..f6ed94e0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Microsoft Agent 365 SDK Tests -Unit and integration tests for the Microsoft Agent 365-Python SDK. This test suite ensures reliability, maintainability, and quality across all modules including runtime, tooling, notifications, and observability extensions. +Unit and package/release tests for Runtime, Tooling, and published distribution metadata. All remaining tests are offline/mocked. The dedicated observability suite, including its live integration tests, has been removed; see the [migration guide](../docs/observability-migration.md). ## Usage diff --git a/tests/RUNNING_TESTS.md b/tests/RUNNING_TESTS.md index 0663fd2a..3aac05a2 100644 --- a/tests/RUNNING_TESTS.md +++ b/tests/RUNNING_TESTS.md @@ -25,14 +25,9 @@ uv venv ### 3. Install Dependencies ```powershell -# Test framework and reporting -uv pip install pytest pytest-asyncio pytest-mock pytest-cov pytest-html wrapt - -# SDK core libraries -uv pip install -e libraries/microsoft-agents-a365-runtime -e libraries/microsoft-agents-a365-notifications -e libraries/microsoft-agents-a365-observability-core -e libraries/microsoft-agents-a365-tooling - -# Framework extension libraries -uv pip install -e libraries/microsoft-agents-a365-observability-extensions-langchain -e libraries/microsoft-agents-a365-observability-extensions-openai -e libraries/microsoft-agents-a365-observability-extensions-semantickernel -e libraries/microsoft-agents-a365-observability-extensions-agentframework -e libraries/microsoft-agents-a365-tooling-extensions-agentframework -e libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry -e libraries/microsoft-agents-a365-tooling-extensions-openai -e libraries/microsoft-agents-a365-tooling-extensions-semantickernel -e libraries/microsoft-agents-a365-tooling-extensions-googleadk +# Install all eight remaining workspace libraries and their test/development extras. +$env:PYTHONPATH = Join-Path (Get-Location) "versioning\helper" +uv sync --locked --all-extras --dev ``` --- @@ -44,11 +39,15 @@ uv pip install -e libraries/microsoft-agents-a365-observability-extensions-langc ```plaintext tests/ ├── runtime/ # Runtime tests -├── observability/ # Observability tests ├── tooling/ # Tooling tests -└── notifications/ # Notifications tests +└── test_*.py # Build, distribution metadata, constraints, release gates ``` +All remaining tests are offline/mocked. The retired observability suite contained +the live integration tests, so its credential-only examples and CI/tox integration +job have been removed. The `integration` marker is reserved for future real-service +tests; selecting it currently collects no tests. + --- ## Running Tests in VS Code (Optional) @@ -131,7 +130,7 @@ start coverage.xml |-------|----------| | **Test loading failed** | Clean pyproject.toml, reinstall packages, restart VS Code | | **ImportError: No module named 'pytest'** | `uv pip install pytest pytest-asyncio pytest-mock` | -| **ImportError: No module named 'microsoft_agents_a365'** | `uv pip install -e .` | +| **ImportError: No module named 'microsoft_agents_a365'** | `uv sync --locked --all-extras --dev` | | **Tests not discovered** | Refresh Test Explorer or restart VS Code | ### Fix Steps @@ -149,7 +148,7 @@ $fixed | Set-Content "pyproject.toml" -NoNewline **2. Reinstall packages** ```powershell -uv pip install -e . +uv sync --locked --all-extras --dev ``` **3. Restart VS Code** diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md index 4d0c6e6c..c55da2aa 100644 --- a/tests/TEST_PLAN.md +++ b/tests/TEST_PLAN.md @@ -11,7 +11,7 @@ ## Overview ### Current State -- ✅ Unit tests exist for `observability` and `runtime` modules +- ✅ Unit tests exist for `runtime` modules; observability has [moved out of this repository](../docs/observability-migration.md) - ❌ Missing tests for `tooling` and `notifications` modules - ❌ No integration tests or CI/CD automation @@ -41,7 +41,6 @@ | 1.1 | Runtime unit tests | ✅ Complete | | 1.2 | Tooling unit tests | HIGH | | 1.3 | Notifications unit tests | HIGH | -| 1.4 | Expand observability tests | MEDIUM | | 1.5 | Tooling extension tests | LOW | | 2 | Integration tests | MEDIUM | | 3 | CI/CD automation | HIGH | @@ -85,18 +84,6 @@ --- -### 1.4 Observability Extensions -**Priority:** MEDIUM - -| Extension | Status | -|-----------|--------| -| `agentframework` | ✅ Expand existing | -| `langchain` | ✅ Expand existing | -| `openai` | ✅ Expand existing | -| `semantickernel` | ✅ Expand existing | - ---- - ### 1.5 Tooling Extensions **Priority:** LOW @@ -115,11 +102,9 @@ | Integration | Status | |-------------|--------| -| Runtime + Observability | ❌ Missing | | Tooling + Runtime | ❌ Missing | | Notifications + Runtime | ❌ Missing | | Agent Framework full flow | ❌ Missing | -| LangChain full flow | ❌ Missing | --- diff --git a/tests/observability/__init__.py b/tests/observability/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/tests/observability/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/core/__init__.py b/tests/observability/core/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/tests/observability/core/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/core/exporters/test_enriched_span.py b/tests/observability/core/exporters/test_enriched_span.py deleted file mode 100644 index f9d0e3ee..00000000 --- a/tests/observability/core/exporters/test_enriched_span.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for EnrichedReadableSpan.""" - -import unittest -from unittest.mock import Mock - -from microsoft_agents_a365.observability.core.exporters.enriched_span import EnrichedReadableSpan - - -class TestEnrichedReadableSpan(unittest.TestCase): - """Test suite for EnrichedReadableSpan.""" - - def test_attributes_merges_original_and_extra(self): - """Test that attributes property merges original span attributes with extra attributes.""" - # Create mock span with original attributes - mock_span = Mock() - mock_span.attributes = {"original_key": "original_value", "shared_key": "original"} - - # Create enriched span with extra attributes - extra_attributes = {"extra_key": "extra_value", "shared_key": "overwritten"} - enriched_span = EnrichedReadableSpan(mock_span, extra_attributes) - - # Verify merged attributes - attributes = enriched_span.attributes - self.assertEqual(attributes["original_key"], "original_value") - self.assertEqual(attributes["extra_key"], "extra_value") - self.assertEqual(attributes["shared_key"], "overwritten") # Extra should overwrite original - - def test_delegates_all_properties_to_wrapped_span(self): - """Test that all span properties are delegated to the wrapped span.""" - # Create mock span with all properties - mock_span = Mock() - mock_span.name = "test-span" - mock_span.context = Mock(trace_id=123, span_id=456) - mock_span.get_span_context.return_value = mock_span.context - mock_span.parent = Mock(span_id=789) - mock_span.start_time = 1000000000 - mock_span.end_time = 2000000000 - mock_span.status = Mock(status_code="OK", description=None) - mock_span.kind = "INTERNAL" - mock_span.events = [] - mock_span.links = [] - mock_span.resource = Mock(attributes={"service.name": "test"}) - mock_span.instrumentation_scope = Mock(name="test-scope") - mock_span.attributes = {} - - enriched_span = EnrichedReadableSpan(mock_span, {}) - - # Verify all properties delegate correctly - self.assertEqual(enriched_span.name, "test-span") - self.assertEqual(enriched_span.context, mock_span.context) - self.assertEqual(enriched_span.parent, mock_span.parent) - self.assertEqual(enriched_span.start_time, 1000000000) - self.assertEqual(enriched_span.end_time, 2000000000) - self.assertEqual(enriched_span.status, mock_span.status) - self.assertEqual(enriched_span.kind, "INTERNAL") - self.assertEqual(enriched_span.events, []) - self.assertEqual(enriched_span.links, []) - self.assertEqual(enriched_span.resource, mock_span.resource) - self.assertEqual(enriched_span.instrumentation_scope, mock_span.instrumentation_scope) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/exporters/test_enriching_span_processor.py b/tests/observability/core/exporters/test_enriching_span_processor.py deleted file mode 100644 index 40e3e334..00000000 --- a/tests/observability/core/exporters/test_enriching_span_processor.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for enriching_span_processor module.""" - -import unittest -from unittest.mock import Mock - -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, - get_span_enricher, - register_span_enricher, - unregister_span_enricher, -) - - -class TestSpanEnricherRegistry(unittest.TestCase): - """Test suite for span enricher registration functions.""" - - def setUp(self): - """Ensure clean state before each test.""" - unregister_span_enricher() - - def tearDown(self): - """Clean up after each test.""" - unregister_span_enricher() - - def test_register_and_unregister_enricher(self): - """Test that enricher can be registered and unregistered.""" - - def my_enricher(span): - return span - - # Initially no enricher - self.assertIsNone(get_span_enricher()) - - # Register - register_span_enricher(my_enricher) - self.assertEqual(get_span_enricher(), my_enricher) - - # Unregister - unregister_span_enricher() - self.assertIsNone(get_span_enricher()) - - def test_register_second_enricher_raises_error(self): - """Test that registering a second enricher raises RuntimeError.""" - - def enricher_one(span): - return span - - def enricher_two(span): - return span - - register_span_enricher(enricher_one) - - with self.assertRaises(RuntimeError) as context: - register_span_enricher(enricher_two) - - self.assertIn("already registered", str(context.exception)) - - def test_unregister_when_none_registered_is_safe(self): - """Test that unregistering when no enricher is registered doesn't raise.""" - # Should not raise - unregister_span_enricher() - self.assertIsNone(get_span_enricher()) - - -class TestEnrichingBatchSpanProcessor(unittest.TestCase): - """Test suite for _EnrichingBatchSpanProcessor.""" - - def setUp(self): - """Ensure clean state before each test.""" - unregister_span_enricher() - - def tearDown(self): - """Clean up after each test.""" - unregister_span_enricher() - - def test_on_end_applies_enricher_to_span(self): - """Test that on_end applies the registered enricher to the span.""" - # Create processor with a mock exporter - mock_exporter = Mock() - processor = _EnrichingBatchSpanProcessor(mock_exporter) - - # Register an enricher that tracks what it receives and returns - received_spans = [] - - def enricher(span): - received_spans.append(span) - # Return a mock enriched span - enriched = Mock(name="enriched_span") - enriched.context = span.context - return enriched - - register_span_enricher(enricher) - - # Create a mock span - original_span = Mock(name="original_span") - original_span.context = Mock() - original_span.context.trace_id = 123 - original_span.context.span_id = 456 - original_span.get_span_context.return_value = original_span.context - - # Call on_end - processor.on_end(original_span) - - # Verify enricher was called with the original span - self.assertEqual(len(received_spans), 1) - self.assertEqual(received_spans[0], original_span) - - # Cleanup - processor.shutdown() - - def test_on_end_continues_if_enricher_raises_exception(self): - """Test that on_end continues processing even if enricher raises an exception.""" - mock_exporter = Mock() - processor = _EnrichingBatchSpanProcessor(mock_exporter) - - def failing_enricher(span): - raise ValueError("Enricher failed!") - - register_span_enricher(failing_enricher) - - # Create a mock span - original_span = Mock(name="original_span") - original_span.context = Mock() - original_span.context.trace_id = 123 - original_span.context.span_id = 456 - original_span.get_span_context.return_value = original_span.context - - # Should not raise despite failing enricher - processor.on_end(original_span) - - # Cleanup - processor.shutdown() - - def test_on_end_works_without_enricher(self): - """Test that on_end works when no enricher is registered.""" - mock_exporter = Mock() - processor = _EnrichingBatchSpanProcessor(mock_exporter) - - # Create a mock span (no enricher registered) - original_span = Mock(name="original_span") - original_span.context = Mock() - original_span.context.trace_id = 123 - original_span.context.span_id = 456 - original_span.get_span_context.return_value = original_span.context - - # Should not raise - processor.on_end(original_span) - - # Cleanup - processor.shutdown() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/exporters/test_payload_chunking.py b/tests/observability/core/exporters/test_payload_chunking.py deleted file mode 100644 index 87095f09..00000000 --- a/tests/observability/core/exporters/test_payload_chunking.py +++ /dev/null @@ -1,245 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for payload byte-size chunking in the Agent 365 exporter.""" - -from __future__ import annotations - -import json -import unittest -from unittest.mock import Mock, patch - -from microsoft_agents_a365.observability.core.constants import ( - CHAT_OPERATION_NAME, - GEN_AI_AGENT_ID_KEY, - GEN_AI_OPERATION_NAME_KEY, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( - _Agent365Exporter, -) -from microsoft_agents_a365.observability.core.exporters.utils import ( - chunk_by_size, - estimate_span_bytes, - estimate_value_bytes, - truncate_span, -) -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExportResult -from opentelemetry.trace import StatusCode - - -def _make_otlp_span(attrs: dict, name: str = "test") -> dict: - return { - "traceId": "00000000000000000000000000000001", - "spanId": "0000000000000002", - "name": name, - "kind": "INTERNAL", - "startTimeUnixNano": 1000000000, - "endTimeUnixNano": 2000000000, - "attributes": attrs, - "events": None, - "links": None, - "status": {"code": "UNSET", "message": ""}, - } - - -class TestEstimateSpanBytes(unittest.TestCase): - def test_over_estimates_relative_to_actual_json_size(self) -> None: - span = _make_otlp_span({ - "gen_ai.system": "openai", - "gen_ai.tool.arguments": "x" * 1000, - "gen_ai.tool.call_result": "y" * 1000, - }) - actual = len(json.dumps(span).encode("utf-8")) - self.assertGreaterEqual(estimate_span_bytes(span), actual) - - def test_grows_with_attribute_content(self) -> None: - small = _make_otlp_span({"key": "val"}) - large = _make_otlp_span({"key": "x" * 10000}) - self.assertGreater(estimate_span_bytes(large), estimate_span_bytes(small)) - - def test_accounts_for_events(self) -> None: - base = _make_otlp_span({}) - with_events = {**base, "events": [{"name": "ev", "attributes": {"k": "v"}}]} - self.assertGreater(estimate_span_bytes(with_events), estimate_span_bytes(base)) - - -class TestEstimateValueBytes(unittest.TestCase): - def test_handles_all_value_types(self) -> None: - self.assertEqual(estimate_value_bytes("hello"), 40 + 5) - self.assertEqual(estimate_value_bytes([]), 60) - self.assertEqual(estimate_value_bytes(["a", "bb"]), 60 + (40 + 1) + (40 + 2)) - self.assertEqual(estimate_value_bytes([1, 2]), 60 + 50 * 2) - self.assertEqual(estimate_value_bytes(True), 40) - self.assertEqual(estimate_value_bytes(42), 40) - self.assertEqual(estimate_value_bytes(None), 40) - - -class TestChunkBySize(unittest.TestCase): - def _get_size(self, item: dict) -> int: - return int(item["size"]) - - def test_empty_input_returns_empty_output(self) -> None: - self.assertEqual(chunk_by_size([], self._get_size, 900_000), []) - - def test_small_items_fit_in_one_chunk(self) -> None: - items = [{"id": f"s{i}", "size": 100} for i in range(10)] - chunks = chunk_by_size(items, self._get_size, 900_000) - self.assertEqual(len(chunks), 1) - self.assertEqual(len(chunks[0]), 10) - - def test_splits_when_cumulative_exceeds_limit_and_preserves_order(self) -> None: - items = [{"id": f"s{i}", "size": 300_000} for i in range(5)] - chunks = chunk_by_size(items, self._get_size, 900_000) - self.assertGreaterEqual(len(chunks), 2) - flat_ids = [item["id"] for chunk in chunks for item in chunk] - self.assertEqual(flat_ids, ["s0", "s1", "s2", "s3", "s4"]) - - def test_oversize_single_item_gets_its_own_chunk(self) -> None: - chunks = chunk_by_size([{"id": "big", "size": 2_000_000}], self._get_size, 900_000) - self.assertEqual(len(chunks), 1) - self.assertEqual(chunks[0][0]["id"], "big") - - def test_multi_item_chunks_respect_limit_no_chunk_is_empty(self) -> None: - items = [{"id": f"s{i}", "size": 200_000} for i in range(5)] - chunks = chunk_by_size(items, self._get_size, 500_000) - for chunk in chunks: - self.assertGreater(len(chunk), 0) - if len(chunk) > 1: - self.assertLessEqual(sum(item["size"] for item in chunk), 500_000) - - def test_rejects_zero_max_chunk_bytes(self) -> None: - with self.assertRaises(ValueError): - chunk_by_size([{"id": "s", "size": 1}], self._get_size, 0) - - def test_rejects_negative_max_chunk_bytes(self) -> None: - with self.assertRaises(ValueError): - chunk_by_size([{"id": "s", "size": 1}], self._get_size, -1) - - def test_rejects_negative_item_size(self) -> None: - with self.assertRaises(ValueError): - chunk_by_size([{"id": "s", "size": -1}], self._get_size, 100) - - -class TestTruncateSpanVerification(unittest.TestCase): - MAX = 250 * 1024 - - def test_leaves_small_span_unchanged(self) -> None: - span = _make_otlp_span({"k": "small"}) - self.assertEqual(truncate_span(span)["attributes"]["k"], "small") - - def test_shrinks_oversize_span_below_limit(self) -> None: - span = _make_otlp_span({"small": "ok", "fat": "x" * 300_000}) - result = truncate_span(span) - self.assertLessEqual(len(json.dumps(result).encode("utf-8")), self.MAX) - self.assertEqual(result["attributes"]["small"], "ok") - self.assertEqual(result["attributes"]["fat"], "TRUNCATED") - - -class TestExporterChunking(unittest.TestCase): - """End-to-end test: the exporter splits oversized batches into multiple HTTP requests.""" - - def setUp(self) -> None: - self.token_resolver = Mock(return_value="test_token") - - def _make_span(self, span_id: int, attribute_size: int) -> ReadableSpan: - mock_span = Mock(spec=ReadableSpan) - mock_span.name = f"span_{span_id}" - - ctx = Mock() - ctx.trace_id = 0x1 - ctx.span_id = span_id - mock_span.context = ctx - mock_span.get_span_context.return_value = ctx - mock_span.parent = None - mock_span.start_time = 1640995200000000000 - mock_span.end_time = 1640995260000000000 - - status = Mock() - status.status_code = StatusCode.OK - status.description = "" - mock_span.status = status - - kind = Mock() - kind.name = "INTERNAL" - mock_span.kind = kind - - mock_span.attributes = { - TENANT_ID_KEY: "tenant-1", - GEN_AI_AGENT_ID_KEY: "agent-1", - GEN_AI_OPERATION_NAME_KEY: CHAT_OPERATION_NAME, - "payload": "x" * attribute_size, - } - mock_span.events = [] - mock_span.links = [] - - scope = Mock() - scope.name = "test.scope" - scope.version = "1.0" - mock_span.instrumentation_scope = scope - - resource = Mock() - resource.attributes = {"service.name": "test"} - mock_span.resource = resource - return mock_span - - def test_oversized_batch_is_split_into_multiple_requests(self) -> None: - exporter = _Agent365Exporter( - token_resolver=self.token_resolver, - cluster_category="test", - max_payload_bytes=300_000, - ) - - # Each span carries ~200 KB of payload; with a 300 KB chunk limit, - # 5 spans should yield at least 2 chunks. - spans = [self._make_span(span_id=i + 1, attribute_size=200_000) for i in range(5)] - - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.SUCCESS) - self.assertGreaterEqual(mock_post.call_count, 2) - - # Each chunk body must be valid JSON with the expected envelope structure. - total_spans_sent = 0 - for call in mock_post.call_args_list: - url, body, headers = call.args - data = json.loads(body) - self.assertIn("resourceSpans", data) - scope_spans = data["resourceSpans"][0]["scopeSpans"] - for ss in scope_spans: - total_spans_sent += len(ss["spans"]) - self.assertEqual(total_spans_sent, len(spans)) - - def test_chunk_failure_short_circuits_remaining_chunks(self) -> None: - exporter = _Agent365Exporter( - token_resolver=self.token_resolver, - cluster_category="test", - max_payload_bytes=300_000, - ) - spans = [self._make_span(span_id=i + 1, attribute_size=200_000) for i in range(5)] - - with patch.object(exporter, "_post_with_retries", return_value=False) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.FAILURE) - # First chunk fails; remaining chunks must not be sent. - self.assertEqual(mock_post.call_count, 1) - - def test_small_batch_uses_single_request(self) -> None: - exporter = _Agent365Exporter( - token_resolver=self.token_resolver, - cluster_category="test", - ) - spans = [self._make_span(span_id=1, attribute_size=100)] - - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.SUCCESS) - self.assertEqual(mock_post.call_count, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/exporters/test_utils.py b/tests/observability/core/exporters/test_utils.py deleted file mode 100644 index dda81a7a..00000000 --- a/tests/observability/core/exporters/test_utils.py +++ /dev/null @@ -1,155 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import unittest -from unittest.mock import patch - -from microsoft_agents_a365.observability.core.exporters.utils import ( - get_validated_domain_override, - truncate_span, -) - - -class TestUtils(unittest.TestCase): - """Unit tests for utility functions.""" - - def test_truncate_span_if_needed(self): - """Test truncate_span_if_needed with various span sizes.""" - # Small span - should return unchanged - small_span = { - "traceId": "abc123", - "spanId": "def456", - "name": "small_span", - "attributes": {"key1": "value1", "key2": "value2"}, - } - result = truncate_span(small_span) - self.assertIsNotNone(result) - self.assertEqual(result["name"], "small_span") - self.assertEqual(result["attributes"]["key1"], "value1") - - # Large span with large payload attributes - should truncate attributes - large_span = { - "traceId": "abc123", - "spanId": "def456", - "name": "large_span", - "attributes": { - "gen_ai.system": "openai", - "gen_ai.request.model": "gpt-4", - "gen_ai.response.model": "gpt-4", - "gen_ai.input.messages": "x" * 150000, # Large payload - "gen_ai.output.messages": "y" * 150000, # Large payload - "gen_ai.sample.attribute": "x" * 250000, # Large payload - "small_attr": "small_value", - }, - } - result = truncate_span(large_span) - self.assertIsNotNone(result) - # The largest attributes should be truncated first - self.assertEqual(result["attributes"]["gen_ai.input.messages"], "TRUNCATED") - self.assertEqual(result["attributes"]["small_attr"], "small_value") # Unchanged - self.assertEqual(result["attributes"]["gen_ai.sample.attribute"], "TRUNCATED") - - # Extremely large span - should return truncated span even if still large - extreme_span = { - "traceId": "abc123", - "spanId": "def456", - "name": "extreme_span", - "attributes": {f"attr_{i}": "x" * 10000 for i in range(100)}, # Many large attributes - "events": [ - {"name": f"event_{i}", "attributes": {"data": "y" * 10000}} for i in range(50) - ], - } - result = truncate_span(extreme_span) - self.assertIsNotNone(result) # Should always return a span, even if still large - # All attributes should be truncated due to size - for key in result["attributes"]: - self.assertEqual(result["attributes"][key], "TRUNCATED") - - -class TestGetValidatedDomainOverride(unittest.TestCase): - """Unit tests for get_validated_domain_override function.""" - - def test_returns_none_when_env_var_not_set(self): - """Test that function returns None when environment variable is not set.""" - with patch.dict(os.environ, {}, clear=True): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_returns_none_when_env_var_is_empty(self): - """Test that function returns None when environment variable is empty.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": ""}): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_returns_none_when_env_var_is_whitespace(self): - """Test that function returns None when environment variable is only whitespace.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": " "}): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_accepts_valid_domain(self): - """Test that function accepts a valid domain without protocol.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "example.com"}): - result = get_validated_domain_override() - self.assertEqual(result, "example.com") - - def test_accepts_valid_domain_with_port(self): - """Test that function accepts a valid domain with port.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "example.com:8080"}): - result = get_validated_domain_override() - self.assertEqual(result, "example.com:8080") - - def test_accepts_valid_https_url(self): - """Test that function accepts a valid URL with https protocol.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "https://example.com"}): - result = get_validated_domain_override() - self.assertEqual(result, "https://example.com") - - def test_accepts_valid_http_url(self): - """Test that function accepts a valid URL with http protocol.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "http://example.com"}): - result = get_validated_domain_override() - self.assertEqual(result, "http://example.com") - - def test_accepts_valid_http_url_with_port(self): - """Test that function accepts a valid URL with http protocol and port.""" - with patch.dict( - os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "http://localhost:8080"} - ): - result = get_validated_domain_override() - self.assertEqual(result, "http://localhost:8080") - - def test_rejects_invalid_protocol(self): - """Test that function rejects URLs with invalid protocols (not http/https).""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "ftp://example.com"}): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_rejects_domain_with_path(self): - """Test that function rejects domain-only format with path separator.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "example.com/path"}): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_rejects_protocol_without_hostname(self): - """Test that function rejects URLs with protocol but no hostname.""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "https://"}): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_rejects_malformed_url_http_colon(self): - """Test that function rejects malformed URLs like 'http:8080' (missing slashes).""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "http:8080"}): - result = get_validated_domain_override() - self.assertIsNone(result) - - def test_rejects_malformed_url_https_colon(self): - """Test that function rejects malformed URLs like 'https:443' (missing slashes).""" - with patch.dict(os.environ, {"A365_OBSERVABILITY_DOMAIN_OVERRIDE": "https:443"}): - result = get_validated_domain_override() - self.assertIsNone(result) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_agent365.py b/tests/observability/core/test_agent365.py deleted file mode 100644 index ca91e13b..00000000 --- a/tests/observability/core/test_agent365.py +++ /dev/null @@ -1,269 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from unittest.mock import Mock, patch - -from microsoft_agents_a365.observability.core import configure -from microsoft_agents_a365.observability.core.exporters.agent365_exporter_options import ( - Agent365ExporterOptions, -) -from microsoft_agents_a365.observability.core.trace_processor import SpanProcessor -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider - - -class TestAgent365Configure(unittest.TestCase): - """Test suite for Agent365 configuration functionality.""" - - def setUp(self): - """Set up test fixtures.""" - # Reset TelemetryManager state before each test - from microsoft_agents_a365.observability.core.config import _telemetry_manager - from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope - - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - self.mock_token_resolver = Mock() - self.mock_token_resolver.return_value = "test_token_123" - - def tearDown(self): - """Clean up after each test.""" - # Reset the telemetry manager singleton state - from microsoft_agents_a365.observability.core.config import _telemetry_manager - from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope - - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - # Do NOT reset otel_trace._TRACER_PROVIDER to None to avoid NonRecordingSpan issues in other tests - - def test_configure_basic_functionality(self): - """Test configure function with basic parameters and legacy parameters.""" - # Test basic configuration without exporter_options - result = configure( - service_name="test-service", - service_namespace="test-namespace", - ) - self.assertTrue(result, "configure() should return True") - - # Test configuration with legacy parameters - result = configure( - service_name="test-service", - service_namespace="test-namespace", - token_resolver=self.mock_token_resolver, - cluster_category="test", - ) - self.assertTrue(result, "configure() should return True with legacy parameters") - - @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") - def test_configure_with_exporter_options_and_parameter_precedence(self, mock_is_enabled): - """Test configure function with exporter_options and verify parameter precedence.""" - # Enable Agent365 exporter for this test - mock_is_enabled.return_value = True - - # Test 1: Basic exporter_options functionality - exporter_options = Agent365ExporterOptions( - cluster_category="dev", - token_resolver=self.mock_token_resolver, - use_s2s_endpoint=True, - max_queue_size=1024, - scheduled_delay_ms=2500, - exporter_timeout_ms=15000, - max_export_batch_size=256, - ) - - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=exporter_options, - ) - self.assertTrue(result, "configure() should return True with exporter_options") - - def test_agent365_exporter_options_keeps_legacy_s2s_false_default(self): - """Deprecated use_s2s_endpoint option keeps legacy False default but routing ignores it.""" - self.assertFalse(Agent365ExporterOptions().use_s2s_endpoint) - - @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") - def test_configure_uses_console_when_exporter_enabled_without_token_resolver( - self, mock_is_enabled - ): - """Existing console fallback remains when exporter is enabled without a resolver.""" - mock_is_enabled.return_value = True - - with patch( - "microsoft_agents_a365.observability.core.config._telemetry_manager._logger" - ) as mock_logger: - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=Agent365ExporterOptions(), - ) - - self.assertTrue(result, "configure() should retain console fallback without a resolver") - mock_logger.warning.assert_called_once_with( - "is_agent365_exporter_enabled() not enabled or token_resolver not set." - " Falling back to console exporter." - ) - - @patch("microsoft_agents_a365.observability.core.config._Agent365Exporter") - @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") - @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") - def test_batch_span_processor_and_exporter_called_with_correct_values( - self, mock_is_enabled, mock_batch_processor, mock_exporter - ): - """Test that BatchSpanProcessor and _Agent365Exporter are called with correct values from exporter_options.""" - # Enable Agent365 exporter for this test - mock_is_enabled.return_value = True - - # Create exporter options with specific values - exporter_options = Agent365ExporterOptions( - cluster_category="staging", - token_resolver=self.mock_token_resolver, - use_s2s_endpoint=True, - max_queue_size=512, - scheduled_delay_ms=1000, - exporter_timeout_ms=10000, - max_export_batch_size=128, - ) - - # Configure with exporter_options - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=exporter_options, - ) - - # Verify configuration succeeded - self.assertTrue(result, "configure() should return True") - - # Verify Agent365Exporter was called with correct parameters - # (suppress_invoke_agent_input is now handled by _EnrichingBatchSpanProcessor) - mock_exporter.assert_called_once_with( - token_resolver=self.mock_token_resolver, - cluster_category="staging", - use_s2s_endpoint=True, - max_payload_bytes=900_000, - ) - - # Verify BatchSpanProcessor was called with correct parameters from exporter_options - mock_batch_processor.assert_called_once() - call_args = mock_batch_processor.call_args - self.assertEqual(call_args.kwargs["max_queue_size"], 512) - self.assertEqual(call_args.kwargs["schedule_delay_millis"], 1000) - self.assertEqual(call_args.kwargs["export_timeout_millis"], 10000) - self.assertEqual(call_args.kwargs["max_export_batch_size"], 128) - - def test_span_processor_creation(self): - """Test SpanProcessor class creation.""" - processor = SpanProcessor() - self.assertIsNotNone(processor, "SpanProcessor should be created successfully") - - def test_configure_prevents_duplicate_initialization(self): - """Test that calling configure() multiple times doesn't reinitialize.""" - result1 = configure( - service_name="test-service-1", - service_namespace="test-namespace-1", - ) - self.assertTrue(result1) - - with patch( - "microsoft_agents_a365.observability.core.config._telemetry_manager._logger" - ) as mock_logger: - result2 = configure( - service_name="test-service-2", - service_namespace="test-namespace-2", - ) - self.assertTrue(result2) - mock_logger.warning.assert_called_once() - self.assertIn("already configured", mock_logger.warning.call_args[0][0].lower()) - - @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") - @patch("microsoft_agents_a365.observability.core.config.trace.get_tracer_provider") - def test_configure_uses_existing_tracer_provider(self, mock_get_provider, mock_is_enabled): - """Test configure() uses existing TracerProvider and adds processors without calling set_tracer_provider.""" - mock_is_enabled.return_value = False - - existing_provider = TracerProvider( - resource=Resource.create({"service.name": "existing-service"}) - ) - mock_get_provider.return_value = existing_provider - - with patch( - "microsoft_agents_a365.observability.core.config._telemetry_manager._logger" - ) as mock_logger: - with patch( - "microsoft_agents_a365.observability.core.config.trace.set_tracer_provider" - ) as mock_set: - result = configure(service_name="new-service", service_namespace="new-namespace") - self.assertTrue(result) - - # Verify existing provider was detected - info_calls = [call[0][0] for call in mock_logger.info.call_args_list] - self.assertTrue( - any("Detected existing TracerProvider" in msg for msg in info_calls) - ) - - # Verify didn't call set_tracer_provider - mock_set.assert_not_called() - - # Verify both processors were added by inspecting the MultiSpanProcessor - - active_processor = existing_provider._active_span_processor - self.assertIsNotNone(active_processor) - - # MultiSpanProcessor has a _span_processors list - processors = active_processor._span_processors - self.assertEqual( - len(processors), - 2, - "Should have 2 processors: BatchSpanProcessor and SpanProcessor", - ) - - # Verify types of processors - processor_types = [type(p).__name__ for p in processors] - self.assertIn("_EnrichingBatchSpanProcessor", processor_types) - self.assertIn("SpanProcessor", processor_types) - - @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") - @patch( - "microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled", - return_value=False, - ) - @patch.dict("os.environ", {"ENABLE_OTLP_EXPORTER": "true"}, clear=True) - def test_otlp_exporter_initialized_when_env_var_set(self, mock_is_enabled, mock_otlp_exporter): - """Test that OTLPSpanExporter is initialized when ENABLE_OTLP_EXPORTER is set.""" - - result = configure( - service_name="test-service", - service_namespace="test-namespace", - ) - - self.assertTrue(result, "configure() should return True") - mock_otlp_exporter.assert_called_once() - - @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") - @patch( - "microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled", - return_value=False, - ) - @patch.dict("os.environ", {}, clear=True) - def test_otlp_exporter_not_initialized_when_env_var_not_set( - self, mock_is_enabled, mock_otlp_exporter - ): - """Test that OTLPSpanExporter is NOT initialized when ENABLE_OTLP_EXPORTER is not set.""" - - result = configure( - service_name="test-service", - service_namespace="test-namespace", - ) - - self.assertTrue(result, "configure() should return True") - mock_otlp_exporter.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_agent365_exporter.py b/tests/observability/core/test_agent365_exporter.py deleted file mode 100644 index 60056109..00000000 --- a/tests/observability/core/test_agent365_exporter.py +++ /dev/null @@ -1,882 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import asyncio -import inspect -import json -import os -import unittest -from unittest.mock import Mock, patch - -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_AGENT_ID_KEY, - GEN_AI_OPERATION_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( - DEFAULT_ENDPOINT_URL, - _Agent365Exporter, -) -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExportResult -from opentelemetry.trace import StatusCode -from opentelemetry.util.types import Attributes - - -class TestAgent365Exporter(unittest.TestCase): - def setUp(self): - """Set up test fixtures.""" - self.mock_token_resolver = Mock() - self.mock_token_resolver.return_value = "test_token_123" - - # Store original environment variable values for cleanup - self._original_domain_override = os.environ.get("A365_OBSERVABILITY_DOMAIN_OVERRIDE") - - # Ensure no override is set by default for most tests - os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None) - - # Create default exporter for tests that don't need special setup - self.exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - def tearDown(self): - """Clean up test environment.""" - # Restore original environment variable value - if self._original_domain_override is None: - os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None) - else: - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = self._original_domain_override - - def _create_mock_span( - self, - name: str = "test_span", - trace_id: int = 12345, - span_id: int = 67890, - parent_id: int | None = None, - status_code: StatusCode = StatusCode.OK, - attributes: Attributes | None = None, - scope_name: str = "test.scope", - scope_version: str = "1.0.0", - tenant_id: str = "test-tenant-123", - agent_id: str = "test-agent-456", - operation_name: str | None = INVOKE_AGENT_OPERATION_NAME, - ) -> ReadableSpan: - """Create a mock ReadableSpan for testing.""" - mock_span = Mock(spec=ReadableSpan) - mock_span.name = name - - # Mock the context properly - mock_context = Mock() - mock_context.trace_id = trace_id - mock_context.span_id = span_id - mock_span.context = mock_context - mock_span.get_span_context.return_value = mock_context - - mock_span.parent = Mock() if parent_id else None - if parent_id: - mock_span.parent.span_id = parent_id - mock_span.start_time = 1640995200000000000 # 2022-01-01 00:00:00 UTC in nanoseconds - mock_span.end_time = 1640995260000000000 # 2022-01-01 00:01:00 UTC in nanoseconds - mock_span.status = Mock() - mock_span.status.status_code = status_code - mock_span.status.description = "" - - # Add kind attribute that kind_name() can process - mock_span.kind = Mock() - mock_span.kind.name = "INTERNAL" - - # Add identity attributes for filter_and_partition_by_identity to work - span_attributes = attributes or {} - if tenant_id and agent_id: - span_attributes.update({ - TENANT_ID_KEY: tenant_id, - GEN_AI_AGENT_ID_KEY: agent_id, - }) - if operation_name is not None and GEN_AI_OPERATION_NAME_KEY not in span_attributes: - span_attributes[GEN_AI_OPERATION_NAME_KEY] = operation_name - - mock_span.attributes = span_attributes - mock_span.events = [] - mock_span.links = [] - - # Mock instrumentation scope - mock_span.instrumentation_scope = Mock() - mock_span.instrumentation_scope.name = scope_name - mock_span.instrumentation_scope.version = scope_version - - # Mock resource for _build_export_request - mock_span.resource = Mock() - mock_span.resource.attributes = {"service.name": "test-service"} - - return mock_span - - def test_export_success(self): - """Test 1: Test successful export of spans.""" - # Arrange - spans = [ - self._create_mock_span("span1", trace_id=111, span_id=222), - self._create_mock_span("span2", trace_id=111, span_id=333, parent_id=222), - ] - - # Mock the _post_with_retries method - with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = self.exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should use default endpoint - args, kwargs = mock_post.call_args - url, body, headers = args - - self.assertIn(DEFAULT_ENDPOINT_URL, url) - self.assertIn( - "/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces", - url, - ) - self.assertEqual(headers["authorization"], "Bearer test_token_123") - self.assertEqual(headers["content-type"], "application/json") - - # Verify JSON structure - request_data = json.loads(body) - self.assertIn("resourceSpans", request_data) - self.assertEqual(len(request_data["resourceSpans"]), 1) # One resource group - self.assertEqual(len(request_data["resourceSpans"][0]["scopeSpans"]), 1) # One scope - self.assertEqual( - len(request_data["resourceSpans"][0]["scopeSpans"][0]["spans"]), 2 - ) # Two spans - - def test_export_fails_after_default_and_fallback_attempts(self): - """Test export failure when default endpoint fails (no fallback in current implementation).""" - # Arrange - spans = [self._create_mock_span("failed_span")] - - # Mock the _post_with_retries method to always return False (failure) - with patch.object(self.exporter, "_post_with_retries", return_value=False) as mock_post: - # Act - result = self.exporter.export(spans) - - # Assert - should fail after default attempt (no fallback in current implementation) - self.assertEqual(result, SpanExportResult.FAILURE) - # Should be called once: only for default URL (no fallback logic) - self.assertEqual(mock_post.call_count, 1) - - # Verify call uses default endpoint - first_url = mock_post.call_args_list[0][0][0] - self.assertIn(DEFAULT_ENDPOINT_URL, first_url) - - def test_partitioning_by_scope(self): - """Test 3: Test that spans are properly partitioned by instrumentation scope.""" - # Arrange - Create spans with different scopes but same tenant/agent - spans = [ - self._create_mock_span( - "span1", trace_id=111, span_id=222, scope_name="scope.a", scope_version="1.0" - ), - self._create_mock_span( - "span2", trace_id=111, span_id=333, scope_name="scope.a", scope_version="1.0" - ), - self._create_mock_span( - "span3", trace_id=222, span_id=444, scope_name="scope.b", scope_version="2.0" - ), - self._create_mock_span( - "span4", trace_id=222, span_id=555, scope_name="scope.c", scope_version="1.5" - ), - ] - - # Mock the _post_with_retries method - with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = self.exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Get the request body and parse it - args, kwargs = mock_post.call_args - url, body, headers = args - request_data = json.loads(body) - - # Should have 1 resource span (all spans share same resource and identity) - self.assertEqual(len(request_data["resourceSpans"]), 1) - - # Should have 3 scope spans (3 different scopes) - scope_spans = request_data["resourceSpans"][0]["scopeSpans"] - self.assertEqual(len(scope_spans), 3) - - # Verify each scope has correct spans - scope_names = [] - span_counts = [] - - for scope_span in scope_spans: - scope_info = scope_span["scope"] - scope_names.append(scope_info["name"]) - span_counts.append(len(scope_span["spans"])) - - # Sort for consistent testing - scope_data = list(zip(scope_names, span_counts, strict=False)) - scope_data.sort() - - # scope.a should have 2 spans, scope.b and scope.c should have 1 each - expected_scopes = [("scope.a", 2), ("scope.b", 1), ("scope.c", 1)] - self.assertEqual(scope_data, expected_scopes) - - def test_s2s_endpoint_path_when_enabled(self): - """Test 4: Test that S2S endpoint path is used when use_s2s_endpoint is True.""" - # Arrange - Create exporter with S2S endpoint enabled - s2s_exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test", use_s2s_endpoint=True - ) - - spans = [self._create_mock_span("s2s_span")] - - # Mock the _post_with_retries method - with patch.object(s2s_exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = s2s_exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should use S2S path with default endpoint - args, kwargs = mock_post.call_args - url, body, headers = args - - self.assertIn(DEFAULT_ENDPOINT_URL, url) - self.assertIn( - "/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces", - url, - ) - self.assertNotIn( - "/observability/tenants/test-tenant-123/otlp/agents/test-agent-456/traces", url - ) - self.assertEqual(headers["authorization"], "Bearer test_token_123") - self.assertEqual(headers["content-type"], "application/json") - - def test_s2s_endpoint_path_when_legacy_flag_disabled(self): - """Test 5: Test that S2S endpoint path is used when use_s2s_endpoint is False.""" - # Arrange - Create exporter with deprecated S2S flag disabled - default_exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test", use_s2s_endpoint=False - ) - - spans = [self._create_mock_span("default_span")] - - # Mock the _post_with_retries method - with patch.object(default_exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = default_exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should still use S2S path with default endpoint - args, kwargs = mock_post.call_args - url, body, headers = args - - self.assertIn(DEFAULT_ENDPOINT_URL, url) - self.assertIn( - "/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces", - url, - ) - self.assertNotIn("/observability/tenants/", url) - self.assertEqual(headers["authorization"], "Bearer test_token_123") - self.assertEqual(headers["content-type"], "application/json") - - def test_omitted_false_true_legacy_flag_all_route_to_s2s(self): - """Omitted/False/True use_s2s_endpoint values are ignored; export always uses S2S.""" - for use_s2s_endpoint in (None, False, True): - with self.subTest(use_s2s_endpoint=use_s2s_endpoint): - kwargs = {} - if use_s2s_endpoint is not None: - kwargs["use_s2s_endpoint"] = use_s2s_endpoint - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, - cluster_category="test", - **kwargs, - ) - spans = [self._create_mock_span("legacy_flag_span")] - - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.SUCCESS) - url = mock_post.call_args[0][0] - self.assertIn("/observabilityService/tenants/", url) - self.assertNotIn("/observability/tenants/", url) - - def test_empty_token_fails_without_sending_request(self): - """Empty app-only tokens fail the export batch without an HTTP request.""" - for token in (None, "", " "): - with self.subTest(token=token): - resolver = Mock(return_value=token) - exporter = _Agent365Exporter(token_resolver=resolver, cluster_category="test") - spans = [self._create_mock_span("empty_token_span")] - - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.FAILURE) - resolver.assert_called_once_with("test-agent-456", "test-tenant-123") - mock_post.assert_not_called() - - def test_resolver_failure_fails_without_sending_request(self): - """Resolver acquisition failures fail export without delegated fallback or HTTP.""" - resolver = Mock(side_effect=RuntimeError("app-only acquisition failed")) - exporter = _Agent365Exporter(token_resolver=resolver, cluster_category="test") - spans = [self._create_mock_span("resolver_failure_span")] - - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.FAILURE) - resolver.assert_called_once_with("test-agent-456", "test-tenant-123") - mock_post.assert_not_called() - - def test_async_resolver_is_awaited_for_sync_export(self): - """Async token resolvers are awaited when export runs outside an active event loop.""" - - async def resolver(agent_id, tenant_id): - return f"async-token-{agent_id}-{tenant_id}" - - exporter = _Agent365Exporter(token_resolver=resolver, cluster_category="test") - spans = [self._create_mock_span("async_resolver_span")] - - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - def test_awaitable_resolver_in_active_event_loop_fails_and_is_released(self): - """Inside a running loop, export fails and the resolver's coroutine, future or task is released.""" - - async def pending_token(): - await asyncio.sleep(3600) - return "never-used" - - async def run_in_loop(): - coroutine = pending_token() - future = asyncio.get_running_loop().create_future() - task = asyncio.ensure_future(pending_token()) - for awaitable in (coroutine, future, task): - exporter = _Agent365Exporter( - token_resolver=lambda _agent_id, _tenant_id, value=awaitable: value, - cluster_category="test", - ) - spans = [self._create_mock_span("active_loop_span")] - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - self.assertEqual(exporter.export(spans), SpanExportResult.FAILURE) - mock_post.assert_not_called() - - await asyncio.wait([task], timeout=1) - self.assertEqual(inspect.getcoroutinestate(coroutine), inspect.CORO_CLOSED) - self.assertTrue(future.cancelled()) - self.assertTrue(task.cancelled()) - - asyncio.run(run_in_loop()) - - def test_auth_not_found_errors_do_not_fallback_to_delegated_route(self): - """401/403/404 failures do not trigger a delegated route fallback.""" - for status_code in (401, 403, 404): - with self.subTest(status_code=status_code): - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, - cluster_category="test", - use_s2s_endpoint=False, - ) - spans = [self._create_mock_span("auth_failure_span")] - - with patch("requests.Session.post") as mock_post: - mock_response = Mock() - mock_response.status_code = status_code - mock_response.text = "auth failure" - mock_response.headers = {"x-ms-correlation-id": "corr"} - mock_post.return_value = mock_response - - result = exporter.export(spans) - - self.assertEqual(result, SpanExportResult.FAILURE) - mock_post.assert_called_once() - url = mock_post.call_args[0][0] - self.assertIn("/observabilityService/tenants/", url) - self.assertNotIn("/observability/tenants/", url) - - @patch("microsoft_agents_a365.observability.core.exporters.agent365_exporter.logger") - def test_export_logging(self, mock_logger): - """Test that the exporter logs appropriate messages during export.""" - # Mock successful HTTP response - with patch("requests.Session.post") as mock_post: - mock_response = Mock() - mock_response.status_code = 200 - mock_response.text = "success" - mock_response.headers = {"x-ms-correlation-id": "test-correlation-123"} - mock_post.return_value = mock_response - - # Create test spans - spans = [ - self._create_mock_span( - name="test_span_1", - attributes={ - TENANT_ID_KEY: "test-tenant-123", - GEN_AI_AGENT_ID_KEY: "test-agent-456", - }, - ), - self._create_mock_span( - name="test_span_2", - attributes={ - TENANT_ID_KEY: "test-tenant-123", - GEN_AI_AGENT_ID_KEY: "test-agent-456", - }, - ), - ] - - # Export spans - result = self.exporter.export(spans) - - # Verify export succeeded - self.assertEqual(result, SpanExportResult.SUCCESS) - - # Verify logging calls - should use default endpoint URL - expected_debug_calls = [ - # Should log groups found at DEBUG - unittest.mock.call.debug("Found 1 identity groups with 2 total spans to export"), - # Should log endpoint being used at DEBUG (default endpoint) - unittest.mock.call.debug( - f"Exporting 2 spans to endpoint: {DEFAULT_ENDPOINT_URL}/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces?api-version=1 " - "(tenant: test-tenant-123, agent: test-agent-456)" - ), - # Should log token resolution success at DEBUG - unittest.mock.call.debug( - "App-only OBS token resolved successfully for agent test-agent-456" - ), - # Should log HTTP success at DEBUG - unittest.mock.call.debug( - "HTTP 200 success on attempt 1. " - "Correlation ID: test-correlation-123. Response: success" - ), - ] - - # Check that all expected debug calls were made - for expected_call in expected_debug_calls: - self.assertIn(expected_call, mock_logger.debug.call_args_list) - - @patch("microsoft_agents_a365.observability.core.exporters.agent365_exporter.logger") - def test_export_error_logging(self, mock_logger): - """Test that the exporter logs errors appropriately.""" - # Create spans without tenant/agent identity - explicitly pass None values - spans = [ - self._create_mock_span(name="test_span", attributes={}, tenant_id=None, agent_id=None) - ] - - # Export spans (should succeed but log no identity) - result = self.exporter.export(spans) - - # Verify export succeeded (no identity spans are treated as success) - self.assertEqual(result, SpanExportResult.SUCCESS) - - # Verify info log for no eligible spans - mock_logger.info.assert_called_with("No eligible genAI spans to export; nothing exported.") - - def test_exporter_is_internal(self): - """Test that _Agent365Exporter is marked as internal/private. - - The underscore prefix convention indicates this class is internal to the SDK - and should not be instantiated directly by developers. - """ - - self.assertTrue( - _Agent365Exporter.__name__.startswith("_"), - "Exporter class should be prefixed with underscore to indicate it's private/internal", - ) - - def test_export_uses_domain_override_when_env_var_set(self): - """Test that domain override is used when A365_OBSERVABILITY_DOMAIN_OVERRIDE is set.""" - # Arrange - override_domain = "override.example.com" - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = override_domain - - # Create exporter after setting environment variable so it reads the override - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("override_test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should use override domain with complete URL - args, kwargs = mock_post.call_args - url, body, headers = args - - expected_url = f"https://{override_domain}/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces?api-version=1" - self.assertEqual(url, expected_url) - - def test_export_uses_default_endpoint_when_no_override(self): - """Test that default endpoint URL is used when no override is set.""" - # Arrange - # Ensure override is not set - os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None) - - # Create exporter after clearing environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("default_domain_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should use default endpoint URL - args, kwargs = mock_post.call_args - url, body, headers = args - - expected_url = f"{DEFAULT_ENDPOINT_URL}/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces?api-version=1" - self.assertEqual(url, expected_url) - - def test_export_ignores_empty_domain_override(self): - """Test that empty or whitespace-only domain override is ignored.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = " " # whitespace only - - # Create exporter after setting environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the default endpoint URL is used (override was ignored) - args, kwargs = mock_post.call_args - url = args[0] - self.assertIn(DEFAULT_ENDPOINT_URL, url) - - def test_export_uses_valid_url_override_with_https(self): - """Test that domain override with https:// protocol is accepted and used correctly.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "https://override.example.com" - - # Create exporter after setting environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should use override URL without duplicating protocol - args, kwargs = mock_post.call_args - url, body, headers = args - - expected_url = "https://override.example.com/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces?api-version=1" - self.assertEqual(url, expected_url) - - def test_export_uses_valid_url_override_with_http(self): - """Test that domain override with http:// protocol is accepted and used correctly.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "http://localhost:8080" - - # Create exporter after setting environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should use override URL with http protocol - args, kwargs = mock_post.call_args - url, body, headers = args - - expected_url = "http://localhost:8080/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces?api-version=1" - self.assertEqual(url, expected_url) - - def test_export_uses_valid_domain_override_with_port(self): - """Test that domain override with port (no protocol) is accepted and https:// is prepended.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "example.com:8080" - - # Create exporter after setting environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the call arguments - should prepend https:// to domain with port - args, kwargs = mock_post.call_args - url, body, headers = args - - expected_url = "https://example.com:8080/observabilityService/tenants/test-tenant-123/otlp/agents/test-agent-456/traces?api-version=1" - self.assertEqual(url, expected_url) - - def test_export_ignores_invalid_domain_with_protocol(self): - """Test that domain override with invalid protocol is ignored.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "ftp://invalid.example.com" - - # Create exporter after setting environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the default endpoint URL is used (invalid override was ignored) - args, kwargs = mock_post.call_args - url = args[0] - self.assertIn(DEFAULT_ENDPOINT_URL, url) - - def test_export_ignores_invalid_domain_with_path(self): - """Test that domain override containing path separator is ignored.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "invalid.example.com/path" - - # Create exporter after setting environment variable - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("test_span")] - - # Mock the _post_with_retries method - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - # Verify the default endpoint URL is used (invalid override was ignored) - args, kwargs = mock_post.call_args - url = args[0] - self.assertIn(DEFAULT_ENDPOINT_URL, url) - - def test_export_falls_back_to_il_tenant_on_default_failure(self): - """Test default endpoint failure (no fallback in current implementation).""" - # Arrange - os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None) - - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("fallback_span")] - - # Mock _post_with_retries: call fails (no fallback logic in current implementation) - with patch.object(exporter, "_post_with_retries", return_value=False) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - should fail because no fallback exists in current implementation - self.assertEqual(result, SpanExportResult.FAILURE) - self.assertEqual(mock_post.call_count, 1) - - # Verify call uses default endpoint - first_url = mock_post.call_args_list[0][0][0] - self.assertIn(DEFAULT_ENDPOINT_URL, first_url) - - def test_export_no_fallback_when_domain_override_fails(self): - """Test that no fallback happens when domain override is set and fails.""" - # Arrange - os.environ["A365_OBSERVABILITY_DOMAIN_OVERRIDE"] = "https://custom-override.com" - - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("no_fallback_span")] - - # Mock _post_with_retries to return False (failure) - with patch.object(exporter, "_post_with_retries", return_value=False) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - should fail without fallback - self.assertEqual(result, SpanExportResult.FAILURE) - # Should only be called once (no fallback for override) - mock_post.assert_called_once() - - # Verify the override URL was used - args, kwargs = mock_post.call_args - url = args[0] - self.assertIn("custom-override.com", url) - - def test_export_no_fallback_when_default_succeeds(self): - """Test that no fallback is attempted when default endpoint succeeds.""" - # Arrange - os.environ.pop("A365_OBSERVABILITY_DOMAIN_OVERRIDE", None) - - exporter = _Agent365Exporter( - token_resolver=self.mock_token_resolver, cluster_category="test" - ) - - spans = [self._create_mock_span("success_span")] - - # Mock _post_with_retries to return True (success) - with patch.object(exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - - def test_export_filters_out_non_genai_spans(self): - """Spans without a known gen_ai.operation.name are filtered out.""" - # Arrange: one genAI span and two non-genAI spans (no/unknown operation name) - genai_span = self._create_mock_span("genai_span", trace_id=1, span_id=2) - no_op_span = self._create_mock_span("http_span", trace_id=3, span_id=4, operation_name=None) - unknown_op_span = self._create_mock_span( - "db_span", trace_id=5, span_id=6, operation_name="some_random_op" - ) - - with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = self.exporter.export([genai_span, no_op_span, unknown_op_span]) - - # Assert: only the genAI span is exported - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - _, body, _ = mock_post.call_args[0] - request_data = json.loads(body) - spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] - self.assertEqual(len(spans_out), 1) - self.assertEqual(spans_out[0]["name"], "genai_span") - - def test_export_filters_out_only_non_genai_spans_returns_success(self): - """When all spans are filtered out, export returns SUCCESS without HTTP call.""" - # Arrange - spans = [ - self._create_mock_span("http_span", operation_name=None), - self._create_mock_span("db_span", operation_name="other"), - ] - - with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - # Act - result = self.exporter.export(spans) - - # Assert - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_not_called() - - def test_export_includes_inference_operation_type_chat_spans(self): - """Spans with InferenceOperationType.CHAT value ('Chat') are kept without normalization.""" - # Arrange — server accepts 'Chat' via case-insensitive matching - chat_span = self._create_mock_span( - "chat_span", trace_id=1, span_id=2, operation_name="Chat" - ) - - with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - result = self.exporter.export([chat_span]) - - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_called_once() - _, body, _ = mock_post.call_args[0] - request_data = json.loads(body) - spans_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"] - self.assertEqual(len(spans_out), 1) - # Value is preserved as-is; no normalization - self.assertEqual(spans_out[0]["attributes"][GEN_AI_OPERATION_NAME_KEY], "Chat") - - def test_export_filters_out_unsupported_inference_operation_types(self): - """Spans with TextCompletion / GenerateContent are filtered out.""" - text_completion_span = self._create_mock_span( - "text_completion_span", trace_id=3, span_id=4, operation_name="TextCompletion" - ) - generate_content_span = self._create_mock_span( - "generate_content_span", trace_id=5, span_id=6, operation_name="GenerateContent" - ) - - with patch.object(self.exporter, "_post_with_retries", return_value=True) as mock_post: - result = self.exporter.export([text_completion_span, generate_content_span]) - - # Both are filtered out — nothing to export - self.assertEqual(result, SpanExportResult.SUCCESS) - mock_post.assert_not_called() - - def test_export_does_not_normalize_canonical_operation_names(self): - """invoke_agent / execute_tool / output_messages / chat are not rewritten.""" - cases = ["invoke_agent", "execute_tool", "output_messages", "chat"] - for op in cases: - with self.subTest(operation_name=op): - span = self._create_mock_span( - f"{op}_span", trace_id=1, span_id=2, operation_name=op - ) - with patch.object( - self.exporter, "_post_with_retries", return_value=True - ) as mock_post: - result = self.exporter.export([span]) - self.assertEqual(result, SpanExportResult.SUCCESS) - _, body, _ = mock_post.call_args[0] - request_data = json.loads(body) - span_out = request_data["resourceSpans"][0]["scopeSpans"][0]["spans"][0] - self.assertEqual(span_out["attributes"][GEN_AI_OPERATION_NAME_KEY], op) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_apply_guardrail_scope.py b/tests/observability/core/test_apply_guardrail_scope.py deleted file mode 100644 index 61acd60a..00000000 --- a/tests/observability/core/test_apply_guardrail_scope.py +++ /dev/null @@ -1,365 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import unittest - -from microsoft_agents_a365.observability.core import ( - AgentDetails, - ApplyGuardrailScope, - Channel, - Request, - configure, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import ( - APPLY_GUARDRAIL_OPERATION_NAME, - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_OPERATION_NAME_KEY, - GUARDIAN_ID_KEY, - GUARDIAN_NAME_KEY, - GUARDIAN_PROVIDER_NAME_KEY, - GUARDIAN_VERSION_KEY, - SECURITY_CONTENT_INPUT_HASH_KEY, - SECURITY_CONTENT_INPUT_VALUE_KEY, - SECURITY_CONTENT_MODIFIED_KEY, - SECURITY_CONTENT_OUTPUT_VALUE_KEY, - SECURITY_DECISION_REASON_KEY, - SECURITY_DECISION_TYPE_KEY, - SECURITY_EXTERNAL_EVENT_ID_KEY, - SECURITY_FINDING_EVENT_NAME, - SECURITY_POLICY_DECISION_TYPE_KEY, - SECURITY_POLICY_ID_KEY, - SECURITY_POLICY_NAME_KEY, - SECURITY_POLICY_VERSION_KEY, - SECURITY_RISK_CATEGORY_KEY, - SECURITY_RISK_SCORE_KEY, - SECURITY_RISK_SEVERITY_KEY, - SECURITY_TARGET_TYPE_KEY, -) -from microsoft_agents_a365.observability.core.guardrail_decision_type import ( - GuardrailDecisionType, -) -from microsoft_agents_a365.observability.core.guardrail_details import GuardrailDetails -from microsoft_agents_a365.observability.core.guardrail_finding import GuardrailFinding -from microsoft_agents_a365.observability.core.guardrail_risk_severity import ( - GuardrailRiskSeverity, -) -from microsoft_agents_a365.observability.core.guardrail_target_type import ( - GuardrailTargetType, -) -from microsoft_agents_a365.observability.core.opentelemetry_scope import ( - OpenTelemetryScope, -) -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import SpanKind - - -class TestApplyGuardrailScope(unittest.TestCase): - """Unit tests for ApplyGuardrailScope.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - configure( - service_name="test-guardrail-service", - service_namespace="test-namespace", - ) - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent", - ) - - def setUp(self): - super().setUp() - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - configure( - service_name="test-guardrail-service", - service_namespace="test-namespace", - ) - - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - self.span_exporter.clear() - - def _get_attributes(self, span): - """Helper to get span attributes as a dict.""" - return dict(span.attributes) if span.attributes else {} - - def test_start_sets_required_attributes(self): - """Test that Start sets required attributes.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.DENY, - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - self.assertEqual(len(spans), 1) - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[GEN_AI_OPERATION_NAME_KEY], APPLY_GUARDRAIL_OPERATION_NAME) - self.assertEqual(attrs[SECURITY_DECISION_TYPE_KEY], "deny") - self.assertEqual(attrs[SECURITY_TARGET_TYPE_KEY], GuardrailTargetType.LLM_INPUT) - - def test_start_sets_guardian_attributes(self): - """Test that guardian attributes are set when provided.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_OUTPUT, - decision_type=GuardrailDecisionType.ALLOW, - guardian_name="PII Filter", - guardian_id="guard_abc123", - guardian_provider_name="azure.ai.content_safety", - guardian_version="2.1.0", - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[GUARDIAN_NAME_KEY], "PII Filter") - self.assertEqual(attrs[GUARDIAN_ID_KEY], "guard_abc123") - self.assertEqual(attrs[GUARDIAN_PROVIDER_NAME_KEY], "azure.ai.content_safety") - self.assertEqual(attrs[GUARDIAN_VERSION_KEY], "2.1.0") - - def test_start_sets_policy_attributes(self): - """Test that policy attributes are set when provided.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.TOOL_CALL, - decision_type=GuardrailDecisionType.MODIFY, - policy_id="policy_pii_v2", - policy_name="PII Protection Policy", - policy_version="1.0", - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[SECURITY_POLICY_ID_KEY], "policy_pii_v2") - self.assertEqual(attrs[SECURITY_POLICY_NAME_KEY], "PII Protection Policy") - self.assertEqual(attrs[SECURITY_POLICY_VERSION_KEY], "1.0") - - def test_start_sets_content_attributes(self): - """Test that content attributes are set when provided.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.ALLOW, - content_input_hash="sha256:abc123", - content_modified=True, - external_event_id="ext-001", - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[SECURITY_CONTENT_INPUT_HASH_KEY], "sha256:abc123") - self.assertEqual(attrs[SECURITY_CONTENT_MODIFIED_KEY], True) - self.assertEqual(attrs[SECURITY_EXTERNAL_EVENT_ID_KEY], "ext-001") - - def test_start_builds_activity_name_with_guardian_name(self): - """Test activity name includes guardian name when provided.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.DENY, - guardian_name="Azure Content Safety", - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - self.assertEqual(spans[0].name, "apply_guardrail Azure Content Safety llm_input") - - def test_start_builds_activity_name_without_guardian_name(self): - """Test activity name without guardian name.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.TOOL_CALL, - decision_type=GuardrailDecisionType.ALLOW, - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - self.assertEqual(spans[0].name, "apply_guardrail tool_call") - - def test_record_decision_updates_decision_type(self): - """Test that RecordDecision updates the decision type.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.ALLOW, - ) - - with ApplyGuardrailScope.start(details, self.agent_details) as scope: - scope.record_decision(GuardrailDecisionType.DENY, "Content blocked") - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[SECURITY_DECISION_TYPE_KEY], "deny") - self.assertEqual(attrs[SECURITY_DECISION_REASON_KEY], "Content blocked") - - def test_record_content_output_sets_output_value(self): - """Test that RecordContentOutput sets the output value.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_OUTPUT, - decision_type=GuardrailDecisionType.MODIFY, - ) - - with ApplyGuardrailScope.start(details, self.agent_details) as scope: - scope.record_content_output("sanitized content") - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[SECURITY_CONTENT_OUTPUT_VALUE_KEY], "sanitized content") - - def test_record_finding_adds_event_with_attributes(self): - """Test that RecordFinding adds an event with correct attributes.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.DENY, - ) - - finding = GuardrailFinding( - risk_category="hate_speech", - risk_severity=GuardrailRiskSeverity.HIGH, - policy_decision_type="deny", - policy_id="policy-abc", - risk_score=0.95, - risk_metadata=['{"category":"hate","confidence":0.95}'], - ) - - with ApplyGuardrailScope.start(details, self.agent_details) as scope: - scope.record_finding(finding) - - spans = self.span_exporter.get_finished_spans() - events = spans[0].events - self.assertEqual(len(events), 1) - event = events[0] - self.assertEqual(event.name, SECURITY_FINDING_EVENT_NAME) - - event_attrs = dict(event.attributes) - self.assertEqual(event_attrs[SECURITY_RISK_CATEGORY_KEY], "hate_speech") - self.assertEqual(event_attrs[SECURITY_RISK_SEVERITY_KEY], GuardrailRiskSeverity.HIGH) - self.assertEqual(event_attrs[SECURITY_POLICY_DECISION_TYPE_KEY], "deny") - self.assertEqual(event_attrs[SECURITY_POLICY_ID_KEY], "policy-abc") - self.assertEqual(event_attrs[SECURITY_RISK_SCORE_KEY], 0.95) - - def test_record_finding_multiple_findings(self): - """Test that multiple findings can be recorded.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.DENY, - ) - - with ApplyGuardrailScope.start(details, self.agent_details) as scope: - scope.record_finding(GuardrailFinding("hate_speech", GuardrailRiskSeverity.HIGH)) - scope.record_finding(GuardrailFinding("pii", GuardrailRiskSeverity.MEDIUM)) - - spans = self.span_exporter.get_finished_spans() - self.assertEqual(len(spans[0].events), 2) - - def test_record_finding_raises_on_none(self): - """Test that RecordFinding raises on None.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.ALLOW, - ) - - with self.assertRaises(ValueError): - with ApplyGuardrailScope.start(details, self.agent_details) as scope: - scope.record_finding(None) - - def test_start_sets_request_context(self): - """Test that request context attributes are set when provided.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.ALLOW, - ) - - request = Request( - content="test input", - conversation_id="conv-123", - channel=Channel(name="msteams", link="https://test.link"), - ) - - with ApplyGuardrailScope.start(details, self.agent_details, request=request): - pass - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[SECURITY_CONTENT_INPUT_VALUE_KEY], "test input") - self.assertEqual(attrs[GEN_AI_CONVERSATION_ID_KEY], "conv-123") - self.assertEqual(attrs[CHANNEL_NAME_KEY], "msteams") - self.assertEqual(attrs[CHANNEL_LINK_KEY], "https://test.link") - - def test_span_kind_is_internal(self): - """Test that the span kind defaults to INTERNAL.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.ALLOW, - ) - - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - self.assertEqual(spans[0].kind, SpanKind.INTERNAL) - - def test_all_decision_types(self): - """Test all guardrail decision types are serialized correctly.""" - for decision_type in GuardrailDecisionType: - self.span_exporter.clear() - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=decision_type, - ) - with ApplyGuardrailScope.start(details, self.agent_details): - pass - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - self.assertEqual(attrs[SECURITY_DECISION_TYPE_KEY], decision_type.value) - - def test_request_content_serializes_structured_input(self): - """Test that non-string request content is JSON-serialized.""" - details = GuardrailDetails( - target_type=GuardrailTargetType.LLM_INPUT, - decision_type=GuardrailDecisionType.ALLOW, - ) - - # Use a list of strings (InputMessagesParam allows list[str]) - request = Request( - content=["hello", "world"], - conversation_id="conv-456", - ) - - with ApplyGuardrailScope.start(details, self.agent_details, request=request): - pass - - spans = self.span_exporter.get_finished_spans() - attrs = self._get_attributes(spans[0]) - # Should be JSON-serialized since it's not a plain string - self.assertEqual(attrs[SECURITY_CONTENT_INPUT_VALUE_KEY], '["hello", "world"]') - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py deleted file mode 100644 index ec3862d4..00000000 --- a/tests/observability/core/test_baggage_builder.py +++ /dev/null @@ -1,394 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import unittest - -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_BLUEPRINT_ID_KEY, - GEN_AI_AGENT_EMAIL_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_TYPE_KEY, - GEN_AI_AGENT_VERSION_KEY, - GEN_AI_CALLER_CLIENT_IP_KEY, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, - SERVICE_NAME_KEY, - SESSION_DESCRIPTION_KEY, - SESSION_ID_KEY, - TENANT_ID_KEY, - USER_ID_KEY, -) -from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder -from opentelemetry import baggage, context, 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 - - -class TestBaggageBuilder(unittest.TestCase): - """Test the BaggageBuilder class.""" - - @classmethod - def setUpClass(cls): - """Save the original tracer provider.""" - cls._original_provider = trace.get_tracer_provider() - - @classmethod - def tearDownClass(cls): - """Restore the original tracer provider.""" - if hasattr(cls, "_original_provider"): - trace.set_tracer_provider(cls._original_provider) - # Force OpenTelemetryScope to refresh its tracer - from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope - - OpenTelemetryScope._tracer = None - - def setUp(self): - """Set up test fixtures.""" - # Enable telemetry for tests - os.environ["ENABLE_OBSERVABILITY"] = "true" - - # Clear any existing context/baggage before each test - context.detach(context.attach({})) - - # Create a fresh BaggageBuilder for each test - self.builder = BaggageBuilder() - - def tearDown(self): - """Clean up after each test.""" - # Clear context - context.detach(context.attach({})) - - def test_baggage_builder_sets_values(self): - """Test that BaggageBuilder sets baggage values correctly.""" - tenant = "tenant-1" - agent = "agent-1" - - # Use the baggage builder within a context - with BaggageBuilder().tenant_id(tenant).agent_id(agent).build(): - # Assert inside scope - baggage should be set - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(TENANT_ID_KEY), tenant) - self.assertEqual(current_baggage.get(GEN_AI_AGENT_ID_KEY), agent) - - # Assert after exiting scope - baggage should be restored/cleared - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(TENANT_ID_KEY)) - self.assertIsNone(current_baggage.get(GEN_AI_AGENT_ID_KEY)) - print("✅ BaggageBuilder sets and restores values correctly!") - - def test_all_baggage_keys(self): - """Test all baggage key setter methods.""" - with ( - BaggageBuilder() - .tenant_id("tenant-1") - .agent_id("agent-1") - .agent_type("Foundry") - .agentic_user_id("auid-1") - .agentic_user_email("upn-1") - .agent_blueprint_id("blueprint-1") - .user_id("caller-1") - .user_client_ip("192.168.1.100") - .build() - ): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(TENANT_ID_KEY), "tenant-1") - self.assertEqual(current_baggage.get(GEN_AI_AGENT_ID_KEY), "agent-1") - self.assertEqual(current_baggage.get(GEN_AI_AGENT_TYPE_KEY), "Foundry") - self.assertEqual(current_baggage.get(GEN_AI_AGENT_AUID_KEY), "auid-1") - self.assertEqual(current_baggage.get(GEN_AI_AGENT_EMAIL_KEY), "upn-1") - self.assertEqual(current_baggage.get(GEN_AI_AGENT_BLUEPRINT_ID_KEY), "blueprint-1") - self.assertEqual(current_baggage.get(USER_ID_KEY), "caller-1") - self.assertEqual(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY), "192.168.1.100") - print("✅ All baggage keys work correctly!") - - def test_baggage_propagates_to_child_spans(self): - """Test that baggage values are copied as attributes onto parent and child spans via SpanProcessor.""" - exporter = InMemorySpanExporter() - provider = TracerProvider() - processor = SimpleSpanProcessor(exporter) - provider.add_span_processor(processor) - - # Also add the Microsoft Agent 365 span processor directly - from microsoft_agents_a365.observability.core.trace_processor.span_processor import ( - SpanProcessor as Agent365SpanProcessor, - ) - - agent365_processor = Agent365SpanProcessor() - provider.add_span_processor(agent365_processor) - - tracer = provider.get_tracer(__name__) - - tenant = "tenant-propagation-test" - agent = "agent-propagation-test" - - # Create baggage before starting spans so processor can copy it - with BaggageBuilder().tenant_id(tenant).agent_id(agent).build(): - with tracer.start_as_current_span("parent_span"): - # Nested child span should also receive baggage-derived attributes at start - with tracer.start_as_current_span("child_span"): - pass # Just create the spans, attributes are set by the processor - - # Ensure spans exported contain these attributes (export happens on end) - finished_spans = exporter.get_finished_spans() - # Find parent and child by name - names = {s.name: s for s in finished_spans} - self.assertIn("parent_span", names, "parent_span not exported") - self.assertIn("child_span", names, "child_span not exported") - self.assertEqual(names["parent_span"].attributes.get(TENANT_ID_KEY), tenant) - self.assertEqual(names["parent_span"].attributes.get(GEN_AI_AGENT_ID_KEY), agent) - self.assertEqual(names["child_span"].attributes.get(TENANT_ID_KEY), tenant) - self.assertEqual(names["child_span"].attributes.get(GEN_AI_AGENT_ID_KEY), agent) - - def test_baggage_reset_after_scope_exit(self): - """Test that all baggage values are completely reset/cleared after exiting scope.""" - # First, set some initial baggage values outside the builder scope - initial_ctx = baggage.set_baggage("existing_key", "existing_value") - context.attach(initial_ctx) - - # Verify initial baggage exists - initial_baggage = baggage.get_all() - self.assertEqual(initial_baggage.get("existing_key"), "existing_value") - - # Use BaggageBuilder to set all possible values - with ( - BaggageBuilder() - .tenant_id("test-tenant") - .agent_id("test-agent") - .agentic_user_id("test-auid") - .agentic_user_email("test-upn") - .agent_blueprint_id("test-blueprint") - .user_id("test-caller") - .build() - ): - # Inside scope - verify all baggage values are set - scoped_baggage = baggage.get_all() - self.assertEqual(scoped_baggage.get(TENANT_ID_KEY), "test-tenant") - self.assertEqual(scoped_baggage.get(GEN_AI_AGENT_ID_KEY), "test-agent") - self.assertEqual(scoped_baggage.get(GEN_AI_AGENT_AUID_KEY), "test-auid") - self.assertEqual(scoped_baggage.get(GEN_AI_AGENT_EMAIL_KEY), "test-upn") - self.assertEqual(scoped_baggage.get(GEN_AI_AGENT_BLUEPRINT_ID_KEY), "test-blueprint") - self.assertEqual(scoped_baggage.get(USER_ID_KEY), "test-caller") - # Original baggage should still exist - self.assertEqual(scoped_baggage.get("existing_key"), "existing_value") - - # After exiting scope - verify ALL BaggageBuilder values are cleared - final_baggage = baggage.get_all() - - # All BaggageBuilder keys should be None/cleared - self.assertIsNone(final_baggage.get(TENANT_ID_KEY)) - self.assertIsNone(final_baggage.get(GEN_AI_AGENT_ID_KEY)) - self.assertIsNone(final_baggage.get(GEN_AI_AGENT_AUID_KEY)) - self.assertIsNone(final_baggage.get(GEN_AI_AGENT_EMAIL_KEY)) - self.assertIsNone(final_baggage.get(GEN_AI_AGENT_BLUEPRINT_ID_KEY)) - self.assertIsNone(final_baggage.get(USER_ID_KEY)) - - # Original baggage should be restored - self.assertEqual(final_baggage.get("existing_key"), "existing_value") - - print("✅ All baggage values are properly reset after scope exit!") - - def test_set_pairs_accepts_dict_and_iterable(self): - """set_pairs should accept both dict and iterable[(k,v)] and apply them to baggage.""" - dict_pairs = { - TENANT_ID_KEY: "tenant-x", - GEN_AI_AGENT_ID_KEY: "agent-x", - } - iter_pairs = [ - (GEN_AI_AGENT_AUID_KEY, "auid-x"), - (GEN_AI_AGENT_EMAIL_KEY, "upn-x"), - ] - - # Also verify that None / whitespace values are ignored - dict_pairs_with_ignored = { - USER_ID_KEY: None, # ignored - } - iter_pairs_with_ignored = [ - (SESSION_ID_KEY, " "), # ignored (whitespace) - ] - - with ( - BaggageBuilder() - .set_pairs(dict_pairs) - .set_pairs(iter_pairs) - .set_pairs(dict_pairs_with_ignored) - .set_pairs(iter_pairs_with_ignored) - .build() - ): - baggage_contents = baggage.get_all() - self.assertEqual(baggage_contents.get(TENANT_ID_KEY), "tenant-x") - self.assertEqual(baggage_contents.get(GEN_AI_AGENT_ID_KEY), "agent-x") - self.assertEqual(baggage_contents.get(GEN_AI_AGENT_AUID_KEY), "auid-x") - self.assertEqual(baggage_contents.get(GEN_AI_AGENT_EMAIL_KEY), "upn-x") - # Ignored values should not be present - self.assertIsNone(baggage_contents.get(USER_ID_KEY)) - self.assertIsNone(baggage_contents.get(SESSION_ID_KEY)) - - def test_session_id_method(self): - """Test session_id method sets session ID baggage.""" - # Should exist and be callable - self.assertTrue(hasattr(self.builder, "session_id")) - self.assertTrue(callable(self.builder.session_id)) - - # Should set session ID baggage - with self.builder.session_id("test-session-123").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SESSION_ID_KEY), "test-session-123") - - def test_session_description_method(self): - """Test session_description method sets session description baggage.""" - # Should exist and be callable - self.assertTrue(hasattr(self.builder, "session_description")) - self.assertTrue(callable(self.builder.session_description)) - - # Should set session description baggage - with self.builder.session_description("test session description").build(): - current_baggage = baggage.get_all() - self.assertEqual( - current_baggage.get(SESSION_DESCRIPTION_KEY), "test session description" - ) - - def test_channel_name_method(self): - """Test channel_name method sets channel name baggage.""" - # Should exist and be callable - self.assertTrue(hasattr(self.builder, "channel_name")) - self.assertTrue(callable(self.builder.channel_name)) - - # Should set channel name baggage - with self.builder.channel_name("Teams Channel").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(CHANNEL_NAME_KEY), "Teams Channel") - - def test_channel_links_method(self): - """Test channel_links method sets channel description baggage.""" - # Should exist and be callable - self.assertTrue(hasattr(self.builder, "channel_links")) - self.assertTrue(callable(self.builder.channel_links)) - - # Should set channel description baggage - with self.builder.channel_links("https://teams.microsoft.com/channel/123").build(): - current_baggage = baggage.get_all() - self.assertEqual( - current_baggage.get(CHANNEL_LINK_KEY), - "https://teams.microsoft.com/channel/123", - ) - - def test_user_client_ip_method(self): - """Test user_client_ip method sets client IP baggage with validation.""" - # Should exist and be callable - self.assertTrue(hasattr(self.builder, "user_client_ip")) - self.assertTrue(callable(self.builder.user_client_ip)) - - # Test valid IPv4 address - with BaggageBuilder().user_client_ip("192.168.1.100").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY), "192.168.1.100") - - # Test valid IPv6 address - with BaggageBuilder().user_client_ip("2001:db8::1").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY), "2001:db8::1") - - # Test None value (should not set baggage) - with BaggageBuilder().user_client_ip(None).build(): - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY)) - - # Test invalid IP address (should be handled gracefully now) - with BaggageBuilder().user_client_ip("not.an.ip.address").build(): - current_baggage = baggage.get_all() - # Should be None due to proper exception handling - self.assertIsNone(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY)) - - def test_operation_source_method(self): - """Test operation_source method sets service name baggage using string values.""" - # Should exist and be callable - self.assertTrue(hasattr(self.builder, "operation_source")) - self.assertTrue(callable(self.builder.operation_source)) - - # Test with custom service name - with BaggageBuilder().operation_source("my-agent-service").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SERVICE_NAME_KEY), "my-agent-service") - - # Test with another service name - with BaggageBuilder().operation_source("weather-bot").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SERVICE_NAME_KEY), "weather-bot") - - # Test with SDK as string - with BaggageBuilder().operation_source("SDK").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SERVICE_NAME_KEY), "SDK") - - # Test with None value (should not set baggage) - with BaggageBuilder().operation_source(None).build(): - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(SERVICE_NAME_KEY)) - - # Test with whitespace-only value (should not set baggage) - with BaggageBuilder().operation_source(" ").build(): - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(SERVICE_NAME_KEY)) - - def test_invoke_agent_server_sets_address_and_port(self): - """Test that invoke_agent_server sets both address and non-443 port.""" - address = "app.azurewebsites.net" - port = 8080 - - with BaggageBuilder().invoke_agent_server(address, port).build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) - self.assertEqual(current_baggage.get(SERVER_PORT_KEY), str(port)) - - # After scope exit, baggage should be cleared - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(SERVER_ADDRESS_KEY)) - self.assertIsNone(current_baggage.get(SERVER_PORT_KEY)) - - def test_invoke_agent_server_omits_port_when_443(self): - """Test that invoke_agent_server omits port when it is the default 443.""" - address = "app.azurewebsites.net" - - with BaggageBuilder().invoke_agent_server(address, 443).build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) - self.assertIsNone(current_baggage.get(SERVER_PORT_KEY)) - - def test_invoke_agent_server_sets_address_only_when_port_none(self): - """Test that invoke_agent_server sets only address when port is None.""" - address = "app.azurewebsites.net" - - with BaggageBuilder().invoke_agent_server(address).build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address) - self.assertIsNone(current_baggage.get(SERVER_PORT_KEY)) - - def test_agent_version_method(self): - """Test agent_version method sets agent version baggage.""" - self.assertTrue(hasattr(self.builder, "agent_version")) - self.assertTrue(callable(self.builder.agent_version)) - - with self.builder.agent_version("1.0.0").build(): - current_baggage = baggage.get_all() - self.assertEqual(current_baggage.get(GEN_AI_AGENT_VERSION_KEY), "1.0.0") - - def test_agent_version_none_not_set(self): - """Test agent_version with None does not set baggage.""" - with BaggageBuilder().agent_version(None).build(): - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(GEN_AI_AGENT_VERSION_KEY)) - - def test_agent_version_whitespace_not_set(self): - """Test agent_version with whitespace-only value does not set baggage.""" - with BaggageBuilder().agent_version(" ").build(): - current_baggage = baggage.get_all() - self.assertIsNone(current_baggage.get(GEN_AI_AGENT_VERSION_KEY)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_custom_start_end_time.py b/tests/observability/core/test_custom_start_end_time.py deleted file mode 100644 index 5a74420f..00000000 --- a/tests/observability/core/test_custom_start_end_time.py +++ /dev/null @@ -1,175 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for custom start and end time support on OpenTelemetry scopes.""" - -import os -import sys -import time -import unittest -from datetime import UTC, datetime -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - ExecuteToolScope, - Request, - SpanDetails, - ToolCallDetails, - configure, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - - -class TestCustomStartEndTime(unittest.TestCase): - """Unit tests for custom start and end time support.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - configure( - service_name="test-custom-time-service", - service_namespace="test-namespace", - ) - # Create test data - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent for custom time testing", - ) - cls.tool_details = ToolCallDetails( - tool_name="test_tool", - arguments='{"arg": "value"}', - tool_call_id="call-123", - ) - - def setUp(self): - super().setUp() - - # Reset TelemetryManager state to ensure fresh configuration - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - # Reconfigure to get a fresh TracerProvider - configure( - service_name="test-custom-time-service", - service_namespace="test-namespace", - ) - - # Set up tracer to capture spans - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - self.span_exporter.clear() - - def _get_finished_span(self): - """Get the last finished span from the exporter.""" - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - return finished_spans[-1] - - def test_custom_start_and_end_time_with_datetime(self): - """Test that datetime objects are correctly converted to span timestamps.""" - custom_start = datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC) - custom_end = datetime(2023, 11, 14, 22, 13, 25, tzinfo=UTC) # 5 seconds later - - scope = ExecuteToolScope.start( - Request(), - self.tool_details, - self.agent_details, - span_details=SpanDetails(start_time=custom_start, end_time=custom_end), - ) - scope.dispose() - - span = self._get_finished_span() - - expected_start_ns = int(custom_start.timestamp() * 1_000_000_000) - expected_end_ns = int(custom_end.timestamp() * 1_000_000_000) - - span_start_ns = span.start_time - span_end_ns = span.end_time - - self.assertAlmostEqual(span_start_ns, expected_start_ns, delta=1000) - self.assertAlmostEqual(span_end_ns, expected_end_ns, delta=1000) - - def test_set_end_time_overrides_end_time(self): - """Test that set_end_time overrides the end time when called before dispose.""" - custom_start = datetime(2023, 11, 14, 22, 13, 40, tzinfo=UTC) - initial_end = datetime(2023, 11, 14, 22, 13, 45, tzinfo=UTC) # 5 seconds after start - later_end = datetime(2023, 11, 14, 22, 13, 48, tzinfo=UTC) # 8 seconds after start - - scope = ExecuteToolScope.start( - Request(), - self.tool_details, - self.agent_details, - span_details=SpanDetails(start_time=custom_start, end_time=initial_end), - ) - # Override the end time - scope.set_end_time(later_end) - scope.dispose() - - span = self._get_finished_span() - - expected_end_ns = int(later_end.timestamp() * 1_000_000_000) - span_end_ns = span.end_time - self.assertAlmostEqual(span_end_ns, expected_end_ns, delta=1000) - - def test_wall_clock_time_used_when_no_custom_times(self): - """Test that wall-clock time is used when no custom times are provided.""" - before = time.time_ns() - scope = ExecuteToolScope.start( - Request(), - self.tool_details, - self.agent_details, - ) - scope.dispose() - after = time.time_ns() - - span = self._get_finished_span() - - span_start_ns = span.start_time - span_end_ns = span.end_time - - # Span times should be within the before/after window - # Allow some tolerance for processing time - self.assertGreaterEqual(span_start_ns, before - 1_000_000) # 1ms tolerance - self.assertLessEqual(span_end_ns, after + 1_000_000) - - def test_only_start_time_provided(self): - """Test that only custom start time can be provided without end time.""" - custom_start = datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC) - - scope = ExecuteToolScope.start( - Request(), - self.tool_details, - self.agent_details, - span_details=SpanDetails(start_time=custom_start), - ) - scope.dispose() - - span = self._get_finished_span() - - expected_start_ns = int(custom_start.timestamp() * 1_000_000_000) - span_start_ns = span.start_time - - # Start time should match what we provided - self.assertAlmostEqual(span_start_ns, expected_start_ns, delta=1000) - - # End time should be close to current time (not custom) - span_end_ns = span.end_time - self.assertGreater(span_end_ns, span_start_ns) - - -if __name__ == "__main__": - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_execute_tool_scope.py b/tests/observability/core/test_execute_tool_scope.py deleted file mode 100644 index a9c292a1..00000000 --- a/tests/observability/core/test_execute_tool_scope.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - Channel, - ExecuteToolScope, - Request, - SpanDetails, - ToolCallDetails, - configure, - extract_context_from_headers, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, -) -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import SpanKind - - -class TestExecuteToolScope(unittest.TestCase): - """Unit tests for ExecuteToolScope and its methods.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - # Configure Microsoft Agent 365 for testing - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - configure( - service_name="test-execute-tool-service", - service_namespace="test-namespace", - ) - # Create test data - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent for tool execution testing", - ) - cls.tool_details = ToolCallDetails( - tool_name="weather_tool", - arguments='{"location": "Seattle", "units": "metric"}', - tool_call_id="call-123", - description="Get current weather information for a location", - ) - - def setUp(self): - super().setUp() - - # Reset TelemetryManager state to ensure fresh configuration - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - # Reconfigure to get a fresh TracerProvider - configure( - service_name="test-execute-tool-service", - service_namespace="test-namespace", - ) - - # Set up tracer to capture spans - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - - self.span_exporter.clear() - - def test_record_response_method_exists(self): - """Test that record_response method exists on ExecuteToolScope.""" - scope = ExecuteToolScope.start(Request(), self.tool_details, self.agent_details) - - if scope is not None: - self.assertTrue(hasattr(scope, "record_response")) - self.assertTrue(callable(scope.record_response)) - scope.dispose() - - def test_request_metadata_set_on_span(self): - """Test that request source metadata is set on span attributes.""" - request = Request( - content="Execute tool with request metadata", - session_id="session-xyz", - channel=Channel(name="Channel 1", link="Link to channel"), - ) - - scope = ExecuteToolScope.start(request, self.tool_details, self.agent_details) - - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - - self.assertIn( - CHANNEL_NAME_KEY, - span_attributes, - "Expected source name to be set on span", - ) - self.assertEqual( - span_attributes[CHANNEL_NAME_KEY], - request.channel.name, - ) - - self.assertIn( - CHANNEL_LINK_KEY, - span_attributes, - "Expected source description to be set on span", - ) - self.assertEqual( - span_attributes[CHANNEL_LINK_KEY], - request.channel.link, - ) - - def test_execute_tool_scope_with_parent_context(self): - """Test ExecuteToolScope uses parent_context to link span to parent context.""" - parent_trace_id = "1234567890abcdef1234567890abcdef" - parent_span_id = "abcdefabcdef1234" - traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" - - # Extract context from traceparent header - parent_context = extract_context_from_headers({"traceparent": traceparent}) - - with ExecuteToolScope.start( - Request(), - self.tool_details, - self.agent_details, - span_details=SpanDetails(parent_context=parent_context), - ): - pass - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - - # Verify span inherits parent's trace_id - span_trace_id = f"{span.context.trace_id:032x}" - self.assertEqual(span_trace_id, parent_trace_id) - - # Verify span's parent_span_id matches - self.assertIsNotNone(span.parent, "Expected span to have a parent") - self.assertTrue(hasattr(span.parent, "span_id"), "Expected parent to have span_id") - span_parent_id = f"{span.parent.span_id:016x}" - self.assertEqual(span_parent_id, parent_span_id) - - def test_span_kind_defaults_to_internal(self): - """Test that ExecuteToolScope defaults to SpanKind.INTERNAL.""" - scope = ExecuteToolScope.start(Request(), self.tool_details, self.agent_details) - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - self.assertEqual(finished_spans[-1].kind, SpanKind.INTERNAL) - - def test_span_kind_override_to_client(self): - """Test that ExecuteToolScope accepts SpanKind.CLIENT override.""" - scope = ExecuteToolScope.start( - Request(), - self.tool_details, - self.agent_details, - span_details=SpanDetails(span_kind=SpanKind.CLIENT), - ) - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - self.assertEqual(finished_spans[-1].kind, SpanKind.CLIENT) - - -if __name__ == "__main__": - # Run pytest only on the current file - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_export_config_consistency.py b/tests/observability/core/test_export_config_consistency.py deleted file mode 100644 index 0e057784..00000000 --- a/tests/observability/core/test_export_config_consistency.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Snapshot tests for observability export configuration. - -These tests pin the production export URL path and authentication scope together. -If the export URL path changes (e.g. from a backend migration), the scope must -be reviewed and updated in lockstep — and vice versa. A failure here is a -reminder to verify both values are consistent with the deployed backend. -""" - -import unittest - -from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( - DEFAULT_ENDPOINT_URL, -) -from microsoft_agents_a365.observability.core.exporters.utils import build_export_url -from microsoft_agents_a365.runtime.environment_utils import ( - PROD_OBSERVABILITY_SCOPE, - get_observability_authentication_scope, -) - - -class TestExportConfigConsistency(unittest.TestCase): - """Ensure export URL, endpoint, and auth scope stay in sync. - - These are intentionally pinned snapshot values. If any of the three - production constants change (endpoint, scope, URL path), **all three - tests below will likely need updating together**. That forced review is - the whole point — it prevents one value from drifting without the others. - """ - - # ---- pinned production values ---- - - EXPECTED_ENDPOINT = "https://agent365.svc.cloud.microsoft" - EXPECTED_SCOPE = "api://9b975845-388f-4429-889e-eab1ef63949c/.default" - EXPECTED_S2S_PATH = "/observabilityService/tenants/{tid}/otlp/agents/{aid}/traces" - - # ---- snapshot assertions ---- - - def test_default_endpoint_url(self): - """DEFAULT_ENDPOINT_URL must match the expected production endpoint.""" - self.assertEqual( - DEFAULT_ENDPOINT_URL, - self.EXPECTED_ENDPOINT, - "DEFAULT_ENDPOINT_URL changed — also review PROD_OBSERVABILITY_SCOPE " - "and build_export_url() path. All three must stay in sync.", - ) - - def test_prod_observability_scope_value(self): - """PROD_OBSERVABILITY_SCOPE must match the expected production scope.""" - self.assertEqual( - PROD_OBSERVABILITY_SCOPE, - self.EXPECTED_SCOPE, - "PROD_OBSERVABILITY_SCOPE changed — also review DEFAULT_ENDPOINT_URL " - "and build_export_url() path. All three must stay in sync.", - ) - - def test_export_url_legacy_false_path_structure(self): - """Deprecated false flag must still use the pinned S2S path pattern.""" - url = build_export_url(self.EXPECTED_ENDPOINT, "a1", "t1") - expected = ( - f"{self.EXPECTED_ENDPOINT}" - f"{self.EXPECTED_S2S_PATH.format(tid='t1', aid='a1')}?api-version=1" - ) - self.assertEqual( - url, - expected, - "Export URL path changed — also review PROD_OBSERVABILITY_SCOPE " - "and DEFAULT_ENDPOINT_URL. All three must stay in sync.", - ) - - def test_export_url_s2s_path_structure(self): - """S2S export URL must use the pinned path pattern.""" - url = build_export_url(self.EXPECTED_ENDPOINT, "a1", "t1", use_s2s_endpoint=True) - expected = ( - f"{self.EXPECTED_ENDPOINT}" - f"{self.EXPECTED_S2S_PATH.format(tid='t1', aid='a1')}?api-version=1" - ) - self.assertEqual( - url, - expected, - "S2S export URL path changed — also review PROD_OBSERVABILITY_SCOPE " - "and DEFAULT_ENDPOINT_URL. All three must stay in sync.", - ) - - def test_scope_and_endpoint_are_coherent(self): - """Auth scope and endpoint must both target the agent365 service. - - This is a coarse sanity check: if the endpoint domain changes away - from 'agent365' but the scope still references the old AAD app, or - vice versa, something is likely wrong. - """ - scopes = get_observability_authentication_scope() - self.assertEqual(len(scopes), 1) - scope = scopes[0] - - # Scope should request app-only tokens for the OBS resource. - self.assertTrue(scope.endswith("/.default")) - # Endpoint should be the agent365 service - self.assertIn("agent365", DEFAULT_ENDPOINT_URL) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_inference_scope.py b/tests/observability/core/test_inference_scope.py deleted file mode 100644 index 88bc4af5..00000000 --- a/tests/observability/core/test_inference_scope.py +++ /dev/null @@ -1,507 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - Channel, - InferenceCallDetails, - InferenceOperationType, - InferenceScope, - Request, - ServiceEndpoint, - SpanDetails, - configure, - extract_context_from_headers, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.agent_details import AgentDetails -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_TYPE_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_RESPONSE_MODEL_KEY, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, -) -from microsoft_agents_a365.observability.core.models.agent_type import AgentType -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - - -class TestInferenceScope(unittest.TestCase): - """Unit tests for InferenceScope and related classes.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - # Configure Microsoft Agent 365 for testing - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - configure( - service_name="test-inference-service", - service_namespace="test-namespace", - ) - # Create test agent details - cls.agent_details = AgentDetails(agent_id="test-inference-agent") - - def setUp(self): - super().setUp() - - # Reset TelemetryManager state to ensure fresh configuration - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - # Reconfigure to get a fresh TracerProvider - configure( - service_name="test-inference-service", - service_namespace="test-namespace", - ) - - # Set up tracer to capture spans - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - - self.span_exporter.clear() - - def test_inference_operation_type_enum(self): - """Test InferenceOperationType enum values.""" - # Test enum values exist - self.assertIsNotNone(InferenceOperationType.CHAT) - self.assertIsNotNone(InferenceOperationType.TEXT_COMPLETION) - self.assertIsNotNone(InferenceOperationType.GENERATE_CONTENT) - - def test_inference_call_details_creation(self): - """Test InferenceCallDetails creation with required fields.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - self.assertEqual(details.operationName, InferenceOperationType.CHAT) - self.assertEqual(details.model, "gpt-4") - self.assertEqual(details.providerName, "openai") - self.assertIsNone(details.inputTokens) - self.assertIsNone(details.outputTokens) - self.assertIsNone(details.finishReasons) - - def test_inference_call_details_with_all_fields(self): - """Test InferenceCallDetails creation with all fields.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.TEXT_COMPLETION, - model="gpt-3.5-turbo", - providerName="azure-openai", - inputTokens=150, - outputTokens=75, - finishReasons=["stop"], - ) - - self.assertEqual(details.operationName, InferenceOperationType.TEXT_COMPLETION) - self.assertEqual(details.model, "gpt-3.5-turbo") - self.assertEqual(details.providerName, "azure-openai") - self.assertEqual(details.inputTokens, 150) - self.assertEqual(details.outputTokens, 75) - self.assertEqual(details.finishReasons, ["stop"]) - - def test_inference_call_details_requires_request_or_response_model(self): - """Test that an inference call always has model identity.""" - with self.assertRaisesRegex(ValueError, "model or responseModel"): - InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model=None, - providerName="openai", - ) - - def test_inference_scope_emits_model_observation_attributes(self): - """Test the attributes required by the model observation pipeline.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.TEXT_COMPLETION, - model=None, - responseModel="gpt-4.1-2025-04-14", - providerName="openai", - inputTokens=150, - outputTokens=75, - cacheReadInputTokens=30, - cacheWriteInputTokens=10, - endpoint=ServiceEndpoint(hostname="models.example.com", port=443), - ) - agent_details = AgentDetails( - agent_id="test-inference-agent", - agent_type="Foundry", - ) - - scope = InferenceScope.start(Request(), details, agent_details) - scope.dispose() - - span = self.span_exporter.get_finished_spans()[-1] - attributes = span.attributes or {} - - self.assertEqual(attributes[GEN_AI_OPERATION_NAME_KEY], "text_completion") - self.assertEqual(attributes[GEN_AI_PROVIDER_NAME_KEY], "openai") - self.assertEqual(attributes[GEN_AI_RESPONSE_MODEL_KEY], "gpt-4.1-2025-04-14") - self.assertEqual(attributes[GEN_AI_AGENT_ID_KEY], "test-inference-agent") - self.assertEqual(attributes[GEN_AI_AGENT_TYPE_KEY], "Foundry") - self.assertEqual(attributes[SERVER_ADDRESS_KEY], "models.example.com") - self.assertEqual(attributes[SERVER_PORT_KEY], 443) - self.assertEqual(attributes[GEN_AI_USAGE_INPUT_TOKENS_KEY], 150) - self.assertEqual(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_KEY], 75) - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY], 30) - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY], 10) - - def test_inference_scope_operation_name_mapping_per_type(self): - """Each InferenceOperationType maps to its canonical lowercase semconv value, - used for both the span name prefix and gen_ai.operation.name - not hardcoded - to 'chat' for every operation type.""" - cases = [ - (InferenceOperationType.CHAT, "chat"), - (InferenceOperationType.TEXT_COMPLETION, "text_completion"), - (InferenceOperationType.GENERATE_CONTENT, "generate_content"), - ] - for operation_type, expected_name in cases: - with self.subTest(operation_type=operation_type): - details = InferenceCallDetails( - operationName=operation_type, - model="gemini-2.0-flash", - providerName="gcp.gemini", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - scope.dispose() - - span = self.span_exporter.get_finished_spans()[-1] - attributes = span.attributes or {} - - self.assertEqual(attributes[GEN_AI_OPERATION_NAME_KEY], expected_name) - self.assertEqual(span.name, f"{expected_name} gemini-2.0-flash") - - def test_whitespace_only_model_is_normalized_when_response_model_present(self): - """A whitespace-only `model` is normalized to None (not treated as present) - when a valid `responseModel` is also provided.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model=" ", - providerName="openai", - responseModel="gpt-4.1", - ) - - self.assertIsNone(details.model) - self.assertEqual(details.responseModel, "gpt-4.1") - - scope = InferenceScope.start(Request(), details, self.agent_details) - scope.dispose() - - span = self.span_exporter.get_finished_spans()[-1] - attributes = span.attributes or {} - - self.assertEqual(span.name, "chat gpt-4.1") - self.assertNotIn(GEN_AI_REQUEST_MODEL_KEY, attributes) - self.assertEqual(attributes[GEN_AI_RESPONSE_MODEL_KEY], "gpt-4.1") - - def test_agent_type_enum_member_emits_string_value(self): - """Passing an AgentType enum member for agent_type emits its string - `.value` as the span attribute, instead of being silently dropped by OTel.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - agent_details = AgentDetails( - agent_id="test-inference-agent", - agent_type=AgentType.FOUNDRY, - ) - - scope = InferenceScope.start(Request(), details, agent_details) - scope.dispose() - - span = self.span_exporter.get_finished_spans()[-1] - attributes = span.attributes or {} - - self.assertEqual(attributes[GEN_AI_AGENT_TYPE_KEY], "Foundry") - - def test_inference_scope_start_method(self): - """Test InferenceScope.start() static method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - # Scope might be None if telemetry is disabled - if scope is not None: - self.assertIsInstance(scope, InferenceScope) - # Test that it has context manager methods - self.assertTrue(hasattr(scope, "__enter__")) - self.assertTrue(hasattr(scope, "__exit__")) - self.assertTrue(hasattr(scope, "dispose")) - - def test_inference_scope_with_request(self): - """Test InferenceScope with request parameter.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - request = Request( - content="What is the weather like?", - session_id="test-session-123", - ) - - scope = InferenceScope.start(request, details, self.agent_details) - - # Test that scope can be created with request - if scope is not None: - self.assertIsInstance(scope, InferenceScope) - - def test_request_metadata_set_on_span(self): - """Test that request source metadata is set on span attributes.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - request = Request( - content="Inference request with source metadata", - session_id="session-meta", - channel=Channel(name="Channel 1", link="Link to channel"), - ) - - scope = InferenceScope.start(request, details, self.agent_details) - - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - - self.assertIn( - CHANNEL_NAME_KEY, - span_attributes, - "Expected source name to be set on span", - ) - self.assertEqual( - span_attributes[CHANNEL_NAME_KEY], - request.channel.name, - ) - - self.assertIn( - CHANNEL_LINK_KEY, - span_attributes, - "Expected source description to be set on span", - ) - self.assertEqual( - span_attributes[CHANNEL_LINK_KEY], - request.channel.link, - ) - - def test_inference_scope_context_manager(self): - """Test InferenceScope as context manager.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - inputTokens=100, - outputTokens=50, - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test context manager usage - with scope as ctx_scope: - self.assertIs(ctx_scope, scope) - - # Test optional recording methods if they exist - if hasattr(scope, "record_input_tokens"): - scope.record_input_tokens(120) - - if hasattr(scope, "record_output_tokens"): - scope.record_output_tokens(60) - - if hasattr(scope, "record_finish_reasons"): - scope.record_finish_reasons(["stop", "length"]) - - def test_inference_scope_dispose(self): - """Test InferenceScope dispose method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test manual dispose - scope.dispose() - # Should not raise an exception - self.assertIsInstance(scope, InferenceScope) - - def test_record_input_messages(self): - """Test record_input_messages method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test recording input messages - messages = ["Hello", "How are you?"] - scope.record_input_messages(messages) - # Should not raise an exception - self.assertTrue(hasattr(scope, "record_input_messages")) - - def test_record_output_messages(self): - """Test record_output_messages method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test recording output messages - messages = ["I'm doing well", "Thanks for asking!"] - scope.record_output_messages(messages) - # Should not raise an exception - self.assertTrue(hasattr(scope, "record_output_messages")) - - def test_record_input_tokens(self): - """Test record_input_tokens method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test recording input tokens - scope.record_input_tokens(150) - # Should not raise an exception - self.assertTrue(hasattr(scope, "record_input_tokens")) - - def test_record_output_tokens(self): - """Test record_output_tokens method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test recording output tokens - scope.record_output_tokens(75) - # Should not raise an exception - self.assertTrue(hasattr(scope, "record_output_tokens")) - - def test_record_finish_reasons(self): - """Test record_finish_reasons method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test recording finish reasons - finish_reasons = ["stop", "length"] - scope.record_finish_reasons(finish_reasons) - # Should not raise an exception - self.assertTrue(hasattr(scope, "record_finish_reasons")) - - def test_record_thought_process(self): - """Test record_thought_process method.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - # Test recording thought process - thought_process = "Analyzing user input and generating appropriate response" - scope.record_thought_process(thought_process) - # Should not raise an exception - self.assertTrue(hasattr(scope, "record_thought_process")) - - def test_inference_scope_with_parent_context(self): - """Test InferenceScope uses parent_context to link span to parent context.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - parent_trace_id = "1234567890abcdef1234567890abcdef" - parent_span_id = "abcdefabcdef1234" - traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" - - # Extract context from traceparent header - parent_context = extract_context_from_headers({"traceparent": traceparent}) - - with InferenceScope.start( - Request(), - details, - self.agent_details, - span_details=SpanDetails(parent_context=parent_context), - ): - pass - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - - # Verify span inherits parent's trace_id - span_trace_id = f"{span.context.trace_id:032x}" - self.assertEqual(span_trace_id, parent_trace_id) - - # Verify span's parent_span_id matches - self.assertIsNotNone(span.parent, "Expected span to have a parent") - self.assertTrue(hasattr(span.parent, "span_id"), "Expected parent to have span_id") - span_parent_id = f"{span.parent.span_id:016x}" - self.assertEqual(span_parent_id, parent_span_id) - - -if __name__ == "__main__": - # Run pytest only on the current file - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_invoke_agent_scope.py b/tests/observability/core/test_invoke_agent_scope.py deleted file mode 100644 index aeb6a660..00000000 --- a/tests/observability/core/test_invoke_agent_scope.py +++ /dev/null @@ -1,374 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - CallerDetails, - Channel, - InvokeAgentScope, - InvokeAgentScopeDetails, - Request, - ServiceEndpoint, - SpanDetails, - UserDetails, - configure, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_VERSION_KEY, - GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, - GEN_AI_CALLER_AGENT_EMAIL_KEY, - GEN_AI_CALLER_AGENT_ID_KEY, - GEN_AI_CALLER_AGENT_NAME_KEY, - GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, - GEN_AI_CALLER_AGENT_USER_ID_KEY, - GEN_AI_CALLER_AGENT_VERSION_KEY, - GEN_AI_INPUT_MESSAGES_KEY, - SERVER_ADDRESS_KEY, - SERVER_PORT_KEY, -) -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import SpanKind - - -class TestInvokeAgentScope(unittest.TestCase): - """Unit tests for InvokeAgentScope and its methods.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - # Configure Microsoft Agent 365 for testing - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - configure( - service_name="test-invoke-agent-service", - service_namespace="test-namespace", - ) - # Create test data - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent for invoke scope testing", - ) - cls.invoke_scope_details = InvokeAgentScopeDetails( - endpoint=ServiceEndpoint(hostname="example.com", port=443), - ) - - # Create channel for requests - cls.channel = Channel( - name="Source Channel", - link="Source channel link", - ) - - # Create a comprehensive request object - cls.test_request = Request( - content="Process customer inquiry about order status", - session_id="session-abc123", - channel=cls.channel, - conversation_id="conv-abc123", - ) - - # Create caller details (non-agentic caller) - cls.user_details = UserDetails( - user_id="user-123", - user_email="user@contoso.com", - user_name="John Doe", - user_client_ip="192.168.1.100", - ) - cls.caller_details = CallerDetails( - user_details=cls.user_details, - ) - - # Create caller agent details (agentic caller) - cls.caller_agent_details = AgentDetails( - agent_id="caller-agent-789", - agent_name="Caller Agent", - agent_description="The agent that initiated this request", - agent_blueprint_id="blueprint-456", - agentic_user_id="auid-123", - agentic_user_email="agent@contoso.com", - tenant_id="tenant-789", - agent_platform_id="platform-123", - agent_version="2.1.0", - ) - - def setUp(self): - super().setUp() - - # Reset TelemetryManager state to ensure fresh configuration - - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - # Reconfigure to get a fresh TracerProvider - configure( - service_name="test-invoke-agent-service", - service_namespace="test-namespace", - ) - - # Set up tracer to capture spans - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - - self.span_exporter.clear() - - def test_record_response_method_exists(self): - """Test that record_response method exists on InvokeAgentScope.""" - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - - if scope is not None: - # Test that the method exists - self.assertTrue(hasattr(scope, "record_response")) - self.assertTrue(callable(scope.record_response)) - scope.dispose() - - def test_record_input_messages_method_exists(self): - """Test that record_input_messages method exists on InvokeAgentScope.""" - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - - if scope is not None: - # Test that the method exists - self.assertTrue(hasattr(scope, "record_input_messages")) - self.assertTrue(callable(scope.record_input_messages)) - scope.dispose() - - def test_record_output_messages_method_exists(self): - """Test that record_output_messages method exists on InvokeAgentScope.""" - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - - if scope is not None: - # Test that the method exists - self.assertTrue(hasattr(scope, "record_output_messages")) - self.assertTrue(callable(scope.record_output_messages)) - scope.dispose() - - def test_request_attributes_set_on_span(self): - """Test that request parameters from mock data are available on span attributes.""" - # Create scope with request - scope = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - self.agent_details, - ) - - if scope is not None: - scope.dispose() - - # Check if mock data parameters are available in span attributes - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - # Get attributes from the span - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - - # Verify mock data request parameters are in span attributes - # Check source channel name from mock data - if CHANNEL_NAME_KEY in span_attributes: - self.assertEqual( - span_attributes[CHANNEL_NAME_KEY], - self.channel.name, # From cls.channel.name - ) - - # Check source channel link from mock data - if CHANNEL_LINK_KEY in span_attributes: - self.assertEqual( - span_attributes[CHANNEL_LINK_KEY], - self.channel.link, # From cls.channel.link - ) - - # Check input messages contain request content from mock data - if GEN_AI_INPUT_MESSAGES_KEY in span_attributes: - input_messages = span_attributes[GEN_AI_INPUT_MESSAGES_KEY] - self.assertIn( - self.test_request.content, # From cls.test_request.content - input_messages, - ) - - def test_invoke_agent_scope_span_kind(self): - """Test that InvokeAgentScope creates spans with the correct SpanKind.""" - # Create scope - scope = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - self.agent_details, - ) - - if scope is not None: - scope.dispose() - - # Check that span was created with correct SpanKind - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - # Get the span and verify its kind - span = finished_spans[-1] - self.assertEqual( - span.kind, - SpanKind.CLIENT, - "InvokeAgentScope defaults to CLIENT spans (can be overridden with span_kind parameter)", - ) - - # Test SERVER span kind override - scope_server = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - self.agent_details, - span_details=SpanDetails(span_kind=SpanKind.SERVER), - ) - - if scope_server is not None: - scope_server.dispose() - - # Check that SERVER span was created - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(len(finished_spans) >= 2, "Expected at least two spans to be created") - - # Get the most recent span and verify it's SERVER - server_span = finished_spans[-1] - self.assertEqual( - server_span.kind, - SpanKind.SERVER, - "InvokeAgentScope should create SERVER spans when span_kind parameter is set", - ) - - def test_span_processor_propagates_server_baggage_for_invoke_agent_span(self): - """Test that SpanProcessor propagates server address/port baggage onto invoke_agent spans.""" - from microsoft_agents_a365.observability.core.middleware.baggage_builder import ( - BaggageBuilder, - ) - - server_address = "myagent.azurewebsites.net" - server_port = 8443 - - # Use InvokeAgentScopeDetails without endpoint so the span processor - # propagates server address/port from baggage (not from endpoint). - scope_details_no_endpoint = InvokeAgentScopeDetails() - - # Set server address/port in baggage, then start an invoke_agent span - with BaggageBuilder().invoke_agent_server(server_address, server_port).build(): - scope = InvokeAgentScope.start( - self.test_request, - scope_details_no_endpoint, - self.agent_details, - ) - if scope is not None: - scope.dispose() - - # Processor should propagate server baggage onto the span - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - span_attributes = getattr(span, "attributes", {}) or {} - self.assertEqual(span_attributes.get(SERVER_ADDRESS_KEY), server_address) - self.assertEqual(span_attributes.get(SERVER_PORT_KEY), str(server_port)) - - def test_agent_version_set_on_span(self): - """Test that agent_version from AgentDetails is set on span attributes.""" - agent_with_version = AgentDetails( - agent_id="versioned-agent", - agent_name="Versioned Agent", - agent_version="1.0.0", - ) - scope = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - agent_with_version, - ) - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span") - span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} - self.assertEqual(span_attributes.get(GEN_AI_AGENT_VERSION_KEY), "1.0.0") - - def test_agent_version_not_set_when_none(self): - """Test that agent_version is not set on span when it is None.""" - agent_without_version = AgentDetails( - agent_id="no-version-agent", - agent_name="No Version Agent", - ) - scope = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - agent_without_version, - ) - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span") - span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} - self.assertNotIn(GEN_AI_AGENT_VERSION_KEY, span_attributes) - - def test_caller_agent_version_set_on_span(self): - """Test that caller agent version is emitted on invoke_agent spans.""" - caller_with_version = CallerDetails( - user_details=self.user_details, - caller_agent_details=self.caller_agent_details, - ) - scope = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - self.agent_details, - caller_details=caller_with_version, - ) - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span") - span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_VERSION_KEY), "2.1.0") - - def test_caller_agent_details_all_fields_set_on_span(self): - """Test that all caller agent detail fields are set on span attributes.""" - caller_with_agent = CallerDetails( - user_details=self.user_details, - caller_agent_details=self.caller_agent_details, - ) - scope = InvokeAgentScope.start( - self.test_request, - self.invoke_scope_details, - self.agent_details, - caller_details=caller_with_agent, - ) - if scope is not None: - scope.dispose() - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span") - span_attributes = getattr(finished_spans[-1], "attributes", {}) or {} - - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_NAME_KEY), "Caller Agent") - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_ID_KEY), "caller-agent-789") - self.assertEqual( - span_attributes.get(GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY), "blueprint-456" - ) - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_USER_ID_KEY), "auid-123") - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_EMAIL_KEY), "agent@contoso.com") - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY), "platform-123") - self.assertEqual(span_attributes.get(GEN_AI_CALLER_AGENT_VERSION_KEY), "2.1.0") - - -if __name__ == "__main__": - # Run pytest only on the current file - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_message_utils.py b/tests/observability/core/test_message_utils.py deleted file mode 100644 index 2cdaa878..00000000 --- a/tests/observability/core/test_message_utils.py +++ /dev/null @@ -1,447 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for message_utils conversion, normalization, and serialization helpers.""" - -import json -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core.message_utils import ( - is_string_list, - is_wrapped_messages, - normalize_input_messages, - normalize_output_messages, - serialize_messages, - to_input_messages, - to_output_messages, -) -from microsoft_agents_a365.observability.core.models.messages import ( - BlobPart, - ChatMessage, - FinishReason, - InputMessages, - MessageRole, - OutputMessage, - OutputMessages, - ReasoningPart, - TextPart, - ToolCallRequestPart, -) - - -class TestTypeGuards(unittest.TestCase): - """Tests for is_string_list and is_wrapped_messages type guards.""" - - def test_is_string_list_with_strings(self): - self.assertTrue(is_string_list(["hello", "world"])) - - def test_is_string_list_with_empty_list(self): - self.assertTrue(is_string_list([])) - - def test_is_string_list_with_input_messages(self): - wrapper = InputMessages(messages=[]) - self.assertFalse(is_string_list(wrapper)) - - def test_is_string_list_with_output_messages(self): - wrapper = OutputMessages(messages=[]) - self.assertFalse(is_string_list(wrapper)) - - def test_is_wrapped_messages_with_input_messages(self): - wrapper = InputMessages(messages=[]) - self.assertTrue(is_wrapped_messages(wrapper)) - - def test_is_wrapped_messages_with_output_messages(self): - wrapper = OutputMessages(messages=[]) - self.assertTrue(is_wrapped_messages(wrapper)) - - def test_is_wrapped_messages_with_string_list(self): - self.assertFalse(is_wrapped_messages(["hello"])) - - def test_is_wrapped_messages_with_empty_list(self): - self.assertFalse(is_wrapped_messages([])) - - -class TestConversion(unittest.TestCase): - """Tests for to_input_messages and to_output_messages.""" - - def test_to_input_messages_single(self): - result = to_input_messages(["Hello"]) - self.assertEqual(len(result), 1) - self.assertEqual(result[0].role, MessageRole.USER) - self.assertEqual(len(result[0].parts), 1) - self.assertIsInstance(result[0].parts[0], TextPart) - self.assertEqual(result[0].parts[0].content, "Hello") - - def test_to_input_messages_multiple(self): - result = to_input_messages(["Hello", "How are you?"]) - self.assertEqual(len(result), 2) - self.assertEqual(result[0].parts[0].content, "Hello") - self.assertEqual(result[1].parts[0].content, "How are you?") - - def test_to_input_messages_empty(self): - result = to_input_messages([]) - self.assertEqual(result, []) - - def test_to_output_messages_single(self): - result = to_output_messages(["Response text"]) - self.assertEqual(len(result), 1) - self.assertIsInstance(result[0], OutputMessage) - self.assertEqual(result[0].role, MessageRole.ASSISTANT) - self.assertEqual(result[0].parts[0].content, "Response text") - - def test_to_output_messages_multiple(self): - result = to_output_messages(["First", "Second"]) - self.assertEqual(len(result), 2) - for msg in result: - self.assertEqual(msg.role, MessageRole.ASSISTANT) - - def test_to_output_messages_empty(self): - result = to_output_messages([]) - self.assertEqual(result, []) - - -class TestNormalization(unittest.TestCase): - """Tests for normalize_input_messages and normalize_output_messages.""" - - def test_normalize_input_from_strings(self): - result = normalize_input_messages(["Hello"]) - self.assertIsInstance(result, InputMessages) - self.assertEqual(len(result.messages), 1) - self.assertEqual(result.messages[0].role, MessageRole.USER) - - def test_normalize_input_from_wrapper(self): - wrapper = InputMessages( - messages=[ - ChatMessage(role=MessageRole.SYSTEM, parts=[TextPart(content="System prompt")]) - ] - ) - result = normalize_input_messages(wrapper) - self.assertIs(result, wrapper) - - def test_normalize_output_from_strings(self): - result = normalize_output_messages(["Response"]) - self.assertIsInstance(result, OutputMessages) - self.assertEqual(len(result.messages), 1) - self.assertEqual(result.messages[0].role, MessageRole.ASSISTANT) - - def test_normalize_output_from_wrapper(self): - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Answer")], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - result = normalize_output_messages(wrapper) - self.assertIs(result, wrapper) - - def test_normalize_input_empty_list(self): - result = normalize_input_messages([]) - self.assertIsInstance(result, InputMessages) - self.assertEqual(result.messages, []) - - def test_normalize_output_empty_list(self): - result = normalize_output_messages([]) - self.assertIsInstance(result, OutputMessages) - self.assertEqual(result.messages, []) - - -class TestSerialization(unittest.TestCase): - """Tests for serialize_messages.""" - - def test_serialize_input_messages(self): - wrapper = InputMessages( - messages=[ChatMessage(role=MessageRole.USER, parts=[TextPart(content="Hello")])] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - - self.assertIsInstance(parsed, list) - self.assertEqual(len(parsed), 1) - self.assertEqual(parsed[0]["role"], "user") - self.assertEqual(parsed[0]["parts"][0]["type"], "text") - self.assertEqual(parsed[0]["parts"][0]["content"], "Hello") - - def test_serialize_output_messages(self): - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Response")], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - - self.assertIsInstance(parsed, list) - self.assertEqual(parsed[0]["role"], "assistant") - self.assertEqual(parsed[0]["finish_reason"], "stop") - - def test_serialize_omits_none_values(self): - wrapper = InputMessages( - messages=[ChatMessage(role=MessageRole.USER, parts=[TextPart(content="Hi")])] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - - # name is None so should not appear - self.assertNotIn("name", parsed[0]) - - def test_serialize_complex_parts(self): - wrapper = InputMessages( - messages=[ - ChatMessage( - role=MessageRole.USER, - parts=[ - TextPart(content="Analyze this image"), - BlobPart(modality="image", content="base64data", mime_type="image/png"), - ], - ) - ] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - - parts = parsed[0]["parts"] - self.assertEqual(len(parts), 2) - self.assertEqual(parts[0]["type"], "text") - self.assertEqual(parts[1]["type"], "blob") - self.assertEqual(parts[1]["modality"], "image") - self.assertEqual(parts[1]["content"], "base64data") - - def test_serialize_with_tool_call_part(self): - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[ - ToolCallRequestPart( - name="search", - id="call_123", - arguments={"query": "GDPR"}, - ) - ], - ) - ] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - - part = parsed[0]["parts"][0] - self.assertEqual(part["type"], "tool_call") - self.assertEqual(part["name"], "search") - self.assertEqual(part["id"], "call_123") - self.assertEqual(part["arguments"], {"query": "GDPR"}) - - def test_serialize_with_reasoning_part(self): - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[ - ReasoningPart(content="Checking GDPR Article 5"), - TextPart(content="Based on GDPR..."), - ], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - - parts = parsed[0]["parts"] - self.assertEqual(parts[0]["type"], "reasoning") - self.assertEqual(parts[0]["content"], "Checking GDPR Article 5") - self.assertEqual(parts[1]["type"], "text") - - def test_serialize_unicode(self): - wrapper = InputMessages( - messages=[ - ChatMessage( - role=MessageRole.USER, - parts=[TextPart(content="日本語テスト 🚀")], - ) - ] - ) - result = serialize_messages(wrapper) - self.assertIn("日本語テスト", result) - self.assertIn("🚀", result) - - def test_serialize_empty_messages(self): - wrapper = InputMessages(messages=[]) - result = serialize_messages(wrapper) - parsed = json.loads(result) - self.assertIsInstance(parsed, list) - self.assertEqual(parsed, []) - - -class TestOutputMessageDefaults(unittest.TestCase): - """Tests for OutputMessage default finish_reason.""" - - def test_output_message_defaults_to_stop(self): - msg = OutputMessage(role=MessageRole.ASSISTANT, parts=[TextPart(content="Hi")]) - self.assertEqual(msg.finish_reason, "stop") - - def test_output_message_custom_finish_reason(self): - msg = OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Hi")], - finish_reason=FinishReason.TOOL_CALL.value, - ) - self.assertEqual(msg.finish_reason, "tool_call") - - def test_serialized_output_includes_finish_reason(self): - """finish_reason is always present in serialized output (required per OTel spec).""" - wrapper = OutputMessages( - messages=[OutputMessage(role=MessageRole.ASSISTANT, parts=[TextPart(content="Hi")])] - ) - result = serialize_messages(wrapper) - parsed = json.loads(result) - self.assertEqual(parsed[0]["finish_reason"], "stop") - - -class TestSerializationFormat(unittest.TestCase): - """Ensure ALL serialization paths produce a plain JSON array (no wrapper object). - - Per OTel gen-ai semantic conventions, the serialized form must always be: - [{"role": "...", "parts": [...]}] - Never the old {"version": "...", "messages": [...]} format. - """ - - def _assert_plain_array(self, result: str) -> list: - """Assert result is a JSON array of message dicts with role+parts.""" - parsed = json.loads(result) - self.assertIsInstance(parsed, list, "Serialized output must be a JSON array") - self.assertNotIsInstance(parsed, dict, "Must not be a wrapper object") - for msg in parsed: - self.assertIsInstance(msg, dict) - self.assertIn("role", msg, "Each message must have 'role'") - self.assertIn("parts", msg, "Each message must have 'parts'") - self.assertIsInstance(msg["parts"], list) - self.assertNotIn("version", msg, "No 'version' field in messages") - # Top-level must never contain 'version' or 'messages' keys (old wrapper) - raw_text = result.strip() - self.assertTrue(raw_text.startswith("["), "Must start with '['") - return parsed - - def test_input_from_string(self): - """Single string normalized and serialized.""" - wrapper = normalize_input_messages("Hello") - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(msgs[0]["role"], "user") - self.assertEqual(msgs[0]["parts"][0]["content"], "Hello") - - def test_input_from_string_list(self): - """String list normalized and serialized.""" - wrapper = normalize_input_messages(["First", "Second"]) - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(len(msgs), 2) - self.assertEqual(msgs[0]["parts"][0]["content"], "First") - self.assertEqual(msgs[1]["parts"][0]["content"], "Second") - - def test_input_from_structured(self): - """Pre-built InputMessages serialized.""" - wrapper = InputMessages( - messages=[ - ChatMessage(role=MessageRole.SYSTEM, parts=[TextPart(content="System")]), - ChatMessage(role=MessageRole.USER, parts=[TextPart(content="User")]), - ] - ) - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(msgs[0]["role"], "system") - self.assertEqual(msgs[1]["role"], "user") - - def test_output_from_string(self): - """Single string normalized to output and serialized.""" - wrapper = normalize_output_messages("Response") - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(msgs[0]["role"], "assistant") - self.assertEqual(msgs[0]["parts"][0]["content"], "Response") - self.assertIn("finish_reason", msgs[0]) - - def test_output_from_string_list(self): - """String list normalized to output and serialized.""" - wrapper = normalize_output_messages(["A", "B"]) - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(len(msgs), 2) - for msg in msgs: - self.assertEqual(msg["role"], "assistant") - self.assertIn("finish_reason", msg) - - def test_output_from_structured(self): - """Pre-built OutputMessages serialized.""" - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Done")], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(msgs[0]["finish_reason"], "stop") - - def test_empty_input_messages(self): - """Empty InputMessages serializes to empty array.""" - result = serialize_messages(InputMessages(messages=[])) - parsed = json.loads(result) - self.assertIsInstance(parsed, list) - self.assertEqual(parsed, []) - - def test_empty_output_messages(self): - """Empty OutputMessages serializes to empty array.""" - result = serialize_messages(OutputMessages(messages=[])) - parsed = json.loads(result) - self.assertIsInstance(parsed, list) - self.assertEqual(parsed, []) - - def test_multi_part_message(self): - """Message with multiple parts serializes as plain array.""" - wrapper = InputMessages( - messages=[ - ChatMessage( - role=MessageRole.USER, - parts=[ - TextPart(content="Look at this"), - BlobPart(modality="image", content="base64==", mime_type="image/png"), - ], - ) - ] - ) - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(len(msgs[0]["parts"]), 2) - - def test_tool_call_message(self): - """Tool call parts serialize as plain array.""" - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[ToolCallRequestPart(name="search", id="c1", arguments={"q": "test"})], - finish_reason=FinishReason.TOOL_CALL.value, - ) - ] - ) - result = serialize_messages(wrapper) - msgs = self._assert_plain_array(result) - self.assertEqual(msgs[0]["parts"][0]["type"], "tool_call") - self.assertEqual(msgs[0]["finish_reason"], "tool_call") - - -if __name__ == "__main__": - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_nonrecording_span.py b/tests/observability/core/test_nonrecording_span.py deleted file mode 100644 index 452d510f..00000000 --- a/tests/observability/core/test_nonrecording_span.py +++ /dev/null @@ -1,192 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests that observability code handles NonRecordingSpan gracefully. - -When the TracerProvider has no valid exporter (e.g. token resolver returns -None on the first turn), it produces NonRecordingSpan instances. These only -expose get_span_context() — the .context attribute does not exist. The code -must use get_span_context() everywhere to avoid AttributeError crashes. -""" - -import os -import unittest -from unittest.mock import MagicMock, patch - -from microsoft_agents_a365.observability.core.exporters.enriched_span import ( - EnrichedReadableSpan, -) -from microsoft_agents_a365.observability.core.opentelemetry_scope import ( - OpenTelemetryScope, -) -from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags - - -def _make_non_recording_span(trace_id: int = 0, span_id: int = 0) -> NonRecordingSpan: - """Create a NonRecordingSpan with the given trace/span IDs.""" - return NonRecordingSpan( - SpanContext( - trace_id=trace_id, - span_id=span_id, - is_remote=False, - trace_flags=TraceFlags(0), - ) - ) - - -class TestOpenTelemetryScopeNonRecordingSpan(unittest.TestCase): - """Verify OpenTelemetryScope works when the tracer returns a NonRecordingSpan.""" - - @patch.dict(os.environ, {"ENABLE_OBSERVABILITY": "true"}) - @patch.object(OpenTelemetryScope, "_get_tracer") - def test_init_with_non_recording_span(self, mock_get_tracer: MagicMock) -> None: - """__init__ must not crash when tracer.start_span returns NonRecordingSpan.""" - mock_tracer = MagicMock() - nr_span = _make_non_recording_span(trace_id=0xABCD, span_id=0x1234) - mock_tracer.start_span.return_value = nr_span - mock_get_tracer.return_value = mock_tracer - - # Should not raise AttributeError - scope = OpenTelemetryScope( - operation_name="invoke_agent", - activity_name="test_activity", - ) - - self.assertIs(scope._span, nr_span) - - @patch.dict(os.environ, {"ENABLE_OBSERVABILITY": "true"}) - @patch.object(OpenTelemetryScope, "_get_tracer") - def test_end_with_non_recording_span(self, mock_get_tracer: MagicMock) -> None: - """_end() must not crash when the span is a NonRecordingSpan.""" - mock_tracer = MagicMock() - nr_span = _make_non_recording_span(trace_id=0xABCD, span_id=0x1234) - mock_tracer.start_span.return_value = nr_span - mock_get_tracer.return_value = mock_tracer - - scope = OpenTelemetryScope( - operation_name="invoke_agent", - activity_name="test_activity", - ) - - # Should not raise AttributeError - scope._end() - - @patch.dict(os.environ, {"ENABLE_OBSERVABILITY": "true"}) - @patch.object(OpenTelemetryScope, "_get_tracer") - def test_dispose_with_non_recording_span(self, mock_get_tracer: MagicMock) -> None: - """Full dispose lifecycle must not crash with NonRecordingSpan.""" - mock_tracer = MagicMock() - nr_span = _make_non_recording_span(trace_id=0xABCD, span_id=0x1234) - mock_tracer.start_span.return_value = nr_span - mock_get_tracer.return_value = mock_tracer - - scope = OpenTelemetryScope( - operation_name="invoke_agent", - activity_name="test_activity", - ) - - # Context manager exit should not raise - scope.__exit__(None, None, None) - - -class TestEnrichedReadableSpanNonRecordingSpan(unittest.TestCase): - """Verify EnrichedReadableSpan delegates context via get_span_context().""" - - def test_context_property_uses_get_span_context(self) -> None: - """EnrichedReadableSpan.context must call get_span_context(), not .context.""" - mock_span = MagicMock() - expected_ctx = SpanContext( - trace_id=0xFF, - span_id=0xAA, - is_remote=False, - trace_flags=TraceFlags(0), - ) - mock_span.get_span_context.return_value = expected_ctx - # Ensure .context attribute is NOT used - del mock_span.context - - enriched = EnrichedReadableSpan(mock_span, extra_attributes={"key": "val"}) - - result = enriched.context - self.assertIs(result, expected_ctx) - mock_span.get_span_context.assert_called_once() - - def test_to_json_with_non_recording_span_context(self) -> None: - """to_json() must not crash when wrapped span uses get_span_context().""" - mock_span = MagicMock() - mock_span.name = "test" - mock_span.get_span_context.return_value = SpanContext( - trace_id=0x1234, - span_id=0x5678, - is_remote=False, - trace_flags=TraceFlags(0), - ) - del mock_span.context - mock_span.attributes = {"key": "value"} - mock_span.kind = "INTERNAL" - mock_span.parent = None - mock_span.start_time = 1000000000 - mock_span.end_time = 2000000000 - mock_span.status = None - mock_span.events = [] - mock_span.links = [] - mock_span.resource = None - - enriched = EnrichedReadableSpan(mock_span, extra_attributes={}) - - # Should not raise - json_str = enriched.to_json() - self.assertIn("test", json_str) - self.assertIn("0x00000000000000000000000000001234", json_str) - - -class TestAgent365ExporterMapSpanGetSpanContext(unittest.TestCase): - """Verify _map_span uses get_span_context() instead of .context.""" - - @patch.dict(os.environ, {}, clear=True) - def test_map_span_calls_get_span_context(self) -> None: - """_map_span must use get_span_context(), not .context attribute.""" - from microsoft_agents_a365.observability.core.exporters.agent365_exporter import ( - _Agent365Exporter, - ) - - exporter = _Agent365Exporter(token_resolver=lambda a, t: "token") - - span = MagicMock() - span.name = "test_span" - span.attributes = {"gen_ai.operation.name": "invoke_agent"} - - expected_ctx = SpanContext( - trace_id=0xABCD, - span_id=0x1234, - is_remote=False, - trace_flags=TraceFlags(0), - ) - span.get_span_context.return_value = expected_ctx - # Remove .context so it would fail if accessed - del span.context - - span.parent = None - span.kind = MagicMock() - span.start_time = 1000000000 - span.end_time = 2000000000 - span.status = MagicMock() - span.status.status_code = MagicMock() - span.status.description = "" - span.events = [] - span.links = [] - span.instrumentation_scope = MagicMock() - span.instrumentation_scope.name = "test" - span.instrumentation_scope.version = "1.0" - - # Should not raise AttributeError - mapped = exporter._map_span(span) - - span.get_span_context.assert_called_once() - self.assertIn("traceId", mapped) - self.assertIn("spanId", mapped) - exporter.shutdown() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py deleted file mode 100644 index f2b2526c..00000000 --- a/tests/observability/core/test_output_scope.py +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - Request, - SpanDetails, - configure, - extract_context_from_headers, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import GEN_AI_OUTPUT_MESSAGES_KEY -from microsoft_agents_a365.observability.core.models.response import Response -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - - -class TestOutputScope(unittest.TestCase): - """Unit tests for OutputScope.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - configure( - service_name="test-output-scope-service", - service_namespace="test-namespace", - ) - - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent for output scope testing", - ) - - def setUp(self): - super().setUp() - - # Reset TelemetryManager state - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - configure( - service_name="test-output-scope-service", - service_namespace="test-namespace", - ) - - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - self.span_exporter.clear() - - def _get_last_span(self): - """Helper to get the last finished span and its attributes.""" - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - span = finished_spans[-1] - attributes = getattr(span, "attributes", {}) or {} - return span, attributes - - def test_output_scope_creates_span_with_messages(self): - """Test OutputScope creates span with output messages attribute.""" - response = Response(messages=["First message", "Second message"]) - - with OutputScope.start(Request(), response, self.agent_details): - pass - - span, attributes = self._get_last_span() - - # Verify span name contains operation name and agent id - self.assertIn("output_messages", span.name) - self.assertIn(self.agent_details.agent_id, span.name) - - # Verify output messages are set - self.assertIn(GEN_AI_OUTPUT_MESSAGES_KEY, attributes) - output_value = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - self.assertIn("First message", output_value) - self.assertIn("Second message", output_value) - - def test_record_output_messages_overwrites(self): - """Test record_output_messages overwrites previously set messages.""" - response = Response(messages=["Initial"]) - - with OutputScope.start(Request(), response, self.agent_details) as scope: - scope.record_output_messages(["Final message"]) - - _, attributes = self._get_last_span() - - output_value = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - self.assertNotIn("Initial", output_value) - self.assertIn("Final message", output_value) - - def test_output_scope_with_parent_context(self): - """Test OutputScope uses parent_context to link span to parent context.""" - response = Response(messages=["Test"]) - parent_trace_id = "1234567890abcdef1234567890abcdef" - parent_span_id = "abcdefabcdef1234" - traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" - - # Extract context from traceparent header - parent_context = extract_context_from_headers({"traceparent": traceparent}) - - with OutputScope.start( - Request(), - response, - self.agent_details, - span_details=SpanDetails(parent_context=parent_context), - ): - pass - - span, _ = self._get_last_span() - - # Verify span inherits parent's trace_id - span_trace_id = f"{span.context.trace_id:032x}" - self.assertEqual(span_trace_id, parent_trace_id) - - # Verify span's parent_span_id matches - self.assertIsNotNone(span.parent, "Expected span to have a parent") - self.assertTrue(hasattr(span.parent, "span_id"), "Expected parent to have span_id") - span_parent_id = f"{span.parent.span_id:016x}" - self.assertEqual(span_parent_id, parent_span_id) - - def test_output_scope_dispose(self): - """Test OutputScope dispose method ends the span.""" - response = Response(messages=["Test"]) - - scope = OutputScope.start(Request(), response, self.agent_details) - self.assertIsNotNone(scope) - scope.dispose() - - # Verify span was created and ended - finished_spans = self.span_exporter.get_finished_spans() - self.assertEqual(len(finished_spans), 1) - - -if __name__ == "__main__": - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_output_scope_bounded.py b/tests/observability/core/test_output_scope_bounded.py deleted file mode 100644 index 7ed690dc..00000000 --- a/tests/observability/core/test_output_scope_bounded.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for OutputScope record_output_messages overwrite behavior.""" - -import json -import unittest -from unittest.mock import MagicMock, patch - -from microsoft_agents_a365.observability.core.constants import GEN_AI_OUTPUT_MESSAGES_KEY -from microsoft_agents_a365.observability.core.models.messages import ( - MessageRole, - OutputMessage, - OutputMessages, - TextPart, -) -from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope - - -class TestOutputScopeOverwrite(unittest.TestCase): - """Tests that OutputScope.record_output_messages overwrites (not accumulates).""" - - def _make_scope(self) -> OutputScope: - """Create an OutputScope with mocked dependencies.""" - with patch.object(OutputScope, "__init__", lambda self, *a, **kw: None): - scope = OutputScope.__new__(OutputScope) - scope.set_tag_maybe = MagicMock() - scope._span = MagicMock() - return scope - - def test_record_overwrites_with_strings(self): - """Calling record_output_messages with strings sets the attribute.""" - scope = self._make_scope() - scope.record_output_messages(["Final response"]) - - scope.set_tag_maybe.assert_called() - call_args = scope.set_tag_maybe.call_args - self.assertEqual(call_args[0][0], GEN_AI_OUTPUT_MESSAGES_KEY) - parsed = json.loads(call_args[0][1]) - self.assertIsInstance(parsed, list) - self.assertEqual(parsed[0]["parts"][0]["content"], "Final response") - - def test_record_overwrites_with_structured(self): - """Calling record_output_messages with OutputMessages sets the attribute.""" - scope = self._make_scope() - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Structured")], - ) - ] - ) - scope.record_output_messages(wrapper) - - call_args = scope.set_tag_maybe.call_args - parsed = json.loads(call_args[0][1]) - self.assertEqual(parsed[0]["parts"][0]["content"], "Structured") - - def test_record_overwrites_with_dict(self): - """Calling record_output_messages with dict sets JSON directly.""" - scope = self._make_scope() - scope.record_output_messages({"result": "tool output"}) - - call_args = scope.set_tag_maybe.call_args - parsed = json.loads(call_args[0][1]) - self.assertEqual(parsed["result"], "tool output") - - def test_second_call_replaces_first(self): - """Second call to record_output_messages replaces the first.""" - scope = self._make_scope() - scope.record_output_messages(["First"]) - scope.record_output_messages(["Second"]) - - # Last call should have "Second", not "First" - call_args = scope.set_tag_maybe.call_args - parsed = json.loads(call_args[0][1]) - self.assertNotIn("First", call_args[0][1]) - self.assertEqual(parsed[0]["parts"][0]["content"], "Second") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_record_attributes.py b/tests/observability/core/test_record_attributes.py deleted file mode 100644 index c94a4255..00000000 --- a/tests/observability/core/test_record_attributes.py +++ /dev/null @@ -1,236 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import unittest -import unittest.mock -from unittest.mock import Mock, patch - -from microsoft_agents_a365.observability.core import AgentDetails -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from microsoft_agents_a365.observability.core.span_details import SpanDetails -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 opentelemetry.trace import SpanKind - - -class TestRecordAttributes(unittest.TestCase): - """Test the record_attributes method on OpenTelemetryScope.""" - - @classmethod - def setUpClass(cls): - """Set up test fixtures once for all tests.""" - # Enable telemetry for tests - os.environ["ENABLE_OBSERVABILITY"] = "true" - - # Set up a tracer with in-memory exporter for testing - cls.exporter = InMemorySpanExporter() - - # Try to get existing provider, or create a new one - try: - provider = trace.get_tracer_provider() - # Check if it's the default NoOp provider - if isinstance(provider, trace.ProxyTracerProvider) or not hasattr( - provider, "add_span_processor" - ): - # Create a new provider if it's NoOp - provider = TracerProvider() - trace.set_tracer_provider(provider) - except Exception: - # Create a new provider - provider = TracerProvider() - try: - trace.set_tracer_provider(provider) - except Exception: - pass # Provider already set - - # Add our span processor to whatever provider we have - if hasattr(provider, "add_span_processor"): - provider.add_span_processor(SimpleSpanProcessor(cls.exporter)) - - # Force OpenTelemetryScope to use the new tracer - OpenTelemetryScope._tracer = None - - def setUp(self): - """Clear spans before each test.""" - # Reset TelemetryManager state to ensure fresh configuration - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - - # Create a fresh TracerProvider for this test - provider = TracerProvider() - trace.set_tracer_provider(provider) - provider.add_span_processor(SimpleSpanProcessor(self.exporter)) - - # Force OpenTelemetryScope to refresh its tracer reference - OpenTelemetryScope._tracer = None - self.exporter.clear() - - def tearDown(self): - """Clean up after each test.""" - # Clear exported spans - self.exporter.clear() - - def test_record_attributes_with_dict(self): - """Test recording attributes using a dictionary.""" - agent_details = AgentDetails( - agent_id="test-agent", agent_name="Test Agent", agent_description="A test agent" - ) - - with OpenTelemetryScope( - operation_name="test_operation", - activity_name="test_activity", - agent_details=agent_details, - span_details=SpanDetails(span_kind=SpanKind.INTERNAL), - ) as scope: - # Record custom attributes using a dictionary - attributes = { - "custom.attribute.1": "value1", - "custom.attribute.2": 42, - "custom.attribute.3": True, - } - scope.record_attributes(attributes) - - # Verify the attributes were set - spans = self.exporter.get_finished_spans() - self.assertEqual(len(spans), 1) - - span_attributes = spans[0].attributes - self.assertEqual(span_attributes.get("custom.attribute.1"), "value1") - self.assertEqual(span_attributes.get("custom.attribute.2"), 42) - self.assertEqual(span_attributes.get("custom.attribute.3"), True) - - def test_record_attributes_multiple_calls(self): - """Test that multiple calls to record_attributes accumulate attributes.""" - agent_details = AgentDetails(agent_id="test-agent") - - with OpenTelemetryScope( - operation_name="test_operation", - activity_name="test_activity", - agent_details=agent_details, - span_details=SpanDetails(span_kind=SpanKind.INTERNAL), - ) as scope: - # First batch of attributes - scope.record_attributes({"batch1.key1": "value1", "batch1.key2": "value2"}) - - # Second batch of attributes - scope.record_attributes({"batch2.key1": "value3", "batch2.key2": "value4"}) - - # Verify all attributes were set - spans = self.exporter.get_finished_spans() - self.assertEqual(len(spans), 1) - - span_attributes = spans[0].attributes - self.assertEqual(span_attributes.get("batch1.key1"), "value1") - self.assertEqual(span_attributes.get("batch1.key2"), "value2") - self.assertEqual(span_attributes.get("batch2.key1"), "value3") - self.assertEqual(span_attributes.get("batch2.key2"), "value4") - - def test_record_attributes_with_telemetry_disabled(self): - """Test that record_attributes is a no-op when telemetry is disabled.""" - # Save current state - old_value = os.environ.get("ENABLE_OBSERVABILITY") - - try: - # Disable telemetry - os.environ["ENABLE_OBSERVABILITY"] = "false" - - agent_details = AgentDetails(agent_id="test-agent") - - with OpenTelemetryScope( - operation_name="test_operation", - activity_name="test_activity", - agent_details=agent_details, - span_details=SpanDetails(span_kind=SpanKind.INTERNAL), - ) as scope: - # This should be a no-op - scope.record_attributes({"custom.key": "value"}) - - # Verify no spans were created (the earlier ones might still be there) - # We just verify that no new span with our custom key was added - spans = self.exporter.get_finished_spans() - # Check if any span has our custom attribute (none should) - has_custom_key = any("custom.key" in (s.attributes or {}) for s in spans) - self.assertFalse(has_custom_key) - finally: - # Restore telemetry state - if old_value is not None: - os.environ["ENABLE_OBSERVABILITY"] = old_value - else: - os.environ.pop("ENABLE_OBSERVABILITY", None) - # Re-enable for safety - os.environ["ENABLE_OBSERVABILITY"] = "true" - - @unittest.mock.patch("microsoft_agents_a365.observability.core.opentelemetry_scope.logger") - def test_opentelemetry_scope_logging(self, mock_logger): - """Test that OpenTelemetryScope logs span start and end messages.""" - activity_name = "test_logging_activity" - agent_details = AgentDetails(agent_id="test-agent-logging") - - with OpenTelemetryScope( - operation_name="test_logging_operation", - activity_name=activity_name, - agent_details=agent_details, - span_details=SpanDetails(span_kind=SpanKind.INTERNAL), - ): - pass - - # Get all info log messages - info_messages = [str(call[0][0]) for call in mock_logger.info.call_args_list] - - # Check for span started and ended messages with span ID format - span_started_messages = [ - msg for msg in info_messages if f"Span started: '{activity_name}'" in msg - ] - span_ended_messages = [ - msg for msg in info_messages if f"Span ended: '{activity_name}'" in msg - ] - - self.assertEqual( - len(span_started_messages), 1, "Should log exactly one span started message" - ) - self.assertEqual(len(span_ended_messages), 1, "Should log exactly one span ended message") - - # Verify span ID format in messages - self.assertRegex( - span_started_messages[0], r"Span started: 'test_logging_activity' \([a-f0-9]{16}\)" - ) - self.assertRegex( - span_ended_messages[0], r"Span ended: 'test_logging_activity' \([a-f0-9]{16}\)" - ) - - @unittest.mock.patch("microsoft_agents_a365.observability.core.opentelemetry_scope.logger") - def test_opentelemetry_scope_error_logging(self, mock_logger): - """Test that OpenTelemetryScope logs errors when span creation fails.""" - activity_name = "test_error_activity" - agent_details = AgentDetails(agent_id="test-agent-error") - - # Mock tracer to return None, simulating span creation failure - with patch.object(OpenTelemetryScope, "_get_tracer") as mock_get_tracer: - mock_tracer = Mock() - mock_tracer.start_span.return_value = None - mock_get_tracer.return_value = mock_tracer - - with OpenTelemetryScope( - operation_name="test_error_operation", - activity_name=activity_name, - agent_details=agent_details, - span_details=SpanDetails(span_kind=SpanKind.INTERNAL), - ): - pass - - # Verify the specific error message was logged - error_messages = [str(call[0][0]) for call in mock_logger.error.call_args_list] - expected_message = f"Failed to create span: '{activity_name}' - tracer returned None" - - self.assertTrue( - any(expected_message in msg for msg in error_messages), - f"Should log error message containing: {expected_message}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_scope_messages.py b/tests/observability/core/test_scope_messages.py deleted file mode 100644 index 043610c6..00000000 --- a/tests/observability/core/test_scope_messages.py +++ /dev/null @@ -1,367 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for structured message recording on InvokeAgentScope, InferenceScope, and OutputScope.""" - -import json -import os -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - InferenceCallDetails, - InferenceOperationType, - InferenceScope, - InvokeAgentScope, - InvokeAgentScopeDetails, - Request, - ServiceEndpoint, - configure, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, -) -from microsoft_agents_a365.observability.core.models.messages import ( - ChatMessage, - FinishReason, - InputMessages, - MessageRole, - OutputMessage, - OutputMessages, - ReasoningPart, - TextPart, -) -from microsoft_agents_a365.observability.core.models.response import Response -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - - -class ScopeMessageTestBase(unittest.TestCase): - """Shared setup for scope message tests.""" - - @classmethod - def setUpClass(cls): - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - configure(service_name="test-scope-messages", service_namespace="test") - - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - ) - cls.invoke_scope_details = InvokeAgentScopeDetails( - endpoint=ServiceEndpoint(hostname="example.com", port=443), - ) - cls.inference_details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4o", - providerName="openai", - ) - - def setUp(self): - super().setUp() - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - configure(service_name="test-scope-messages", service_namespace="test") - - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - self.span_exporter.clear() - - def _get_last_span_attrs(self) -> dict: - spans = self.span_exporter.get_finished_spans() - self.assertTrue(spans, "Expected at least one span") - return dict(getattr(spans[-1], "attributes", {}) or {}) - - def _parse_messages(self, attr_value: str) -> list: - parsed = json.loads(attr_value) - self.assertIsInstance(parsed, list) - return parsed - - -class TestInvokeAgentScopeMessages(ScopeMessageTestBase): - """Tests for InvokeAgentScope message recording.""" - - def test_record_input_messages_with_strings(self): - """Plain string list should be auto-wrapped into structured format.""" - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - scope.record_input_messages(["What is GDPR?"]) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 1) - self.assertEqual(parsed[0]["role"], "user") - self.assertEqual(parsed[0]["parts"][0]["content"], "What is GDPR?") - - def test_record_input_messages_with_structured(self): - """Versioned InputMessages wrapper should be serialized as-is.""" - wrapper = InputMessages( - messages=[ - ChatMessage( - role=MessageRole.SYSTEM, - parts=[TextPart(content="You are a compliance assistant.")], - ), - ChatMessage( - role=MessageRole.USER, - parts=[TextPart(content="What are data retention policies?")], - ), - ] - ) - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - scope.record_input_messages(wrapper) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 2) - self.assertEqual(parsed[0]["role"], "system") - self.assertEqual(parsed[1]["role"], "user") - - def test_record_output_messages_with_strings(self): - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - scope.record_output_messages(["GDPR requires data minimization."]) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["role"], "assistant") - self.assertEqual( - parsed[0]["parts"][0]["content"], - "GDPR requires data minimization.", - ) - - def test_record_output_messages_with_structured(self): - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[ - ReasoningPart(content="Checking Article 5(1)(e)"), - TextPart(content="Based on GDPR..."), - ], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - scope.record_output_messages(wrapper) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - msg = parsed[0] - self.assertEqual(msg["finish_reason"], "stop") - self.assertEqual(len(msg["parts"]), 2) - self.assertEqual(msg["parts"][0]["type"], "reasoning") - self.assertEqual(msg["parts"][1]["type"], "text") - - def test_record_response_wraps_string(self): - """record_response(str) should produce structured output messages.""" - scope = InvokeAgentScope.start(Request(), self.invoke_scope_details, self.agent_details) - scope.record_response("Simple response") - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["parts"][0]["content"], "Simple response") - - def test_request_content_string_auto_wrapped(self): - """Request.content as plain string should be wrapped into versioned format.""" - request = Request(content="What is GDPR?") - scope = InvokeAgentScope.start(request, self.invoke_scope_details, self.agent_details) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["role"], "user") - self.assertIn("What is GDPR?", parsed[0]["parts"][0]["content"]) - - def test_request_content_structured_input(self): - """Request.content as InputMessages should be serialized directly.""" - wrapper = InputMessages( - messages=[ChatMessage(role=MessageRole.USER, parts=[TextPart(content="Hello")])] - ) - request = Request(content=wrapper) - scope = InvokeAgentScope.start(request, self.invoke_scope_details, self.agent_details) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["parts"][0]["content"], "Hello") - - -class TestInferenceScopeMessages(ScopeMessageTestBase): - """Tests for InferenceScope message recording.""" - - def test_record_input_messages_with_strings(self): - scope = InferenceScope.start(Request(), self.inference_details, self.agent_details) - scope.record_input_messages(["Explain quantum computing"]) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["role"], "user") - self.assertEqual(parsed[0]["parts"][0]["content"], "Explain quantum computing") - - def test_record_input_messages_with_structured(self): - wrapper = InputMessages( - messages=[ - ChatMessage( - role=MessageRole.SYSTEM, - parts=[TextPart(content="You are helpful.")], - ), - ChatMessage( - role=MessageRole.USER, - parts=[TextPart(content="Question")], - ), - ] - ) - scope = InferenceScope.start(Request(), self.inference_details, self.agent_details) - scope.record_input_messages(wrapper) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 2) - - def test_record_output_messages_with_strings(self): - scope = InferenceScope.start(Request(), self.inference_details, self.agent_details) - scope.record_output_messages(["Quantum computing uses qubits."]) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["role"], "assistant") - - def test_record_output_messages_with_structured(self): - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Answer")], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - scope = InferenceScope.start(Request(), self.inference_details, self.agent_details) - scope.record_output_messages(wrapper) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["finish_reason"], "stop") - - def test_request_content_string_auto_wrapped(self): - request = Request(content="Test content") - scope = InferenceScope.start(request, self.inference_details, self.agent_details) - scope.dispose() - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["parts"][0]["content"], "Test content") - - -class TestOutputScopeMessages(ScopeMessageTestBase): - """Tests for OutputScope structured message support.""" - - def test_initial_string_messages_wrapped(self): - """Response with plain strings should produce structured output.""" - response = Response(messages=["First", "Second"]) - with OutputScope.start(Request(), response, self.agent_details): - pass - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 2) - self.assertEqual(parsed[0]["role"], "assistant") - self.assertEqual(parsed[0]["parts"][0]["content"], "First") - self.assertEqual(parsed[1]["parts"][0]["content"], "Second") - - def test_initial_structured_messages(self): - """Response with OutputMessages should be serialized directly.""" - wrapper = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Structured output")], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - response = Response(messages=wrapper) - with OutputScope.start(Request(), response, self.agent_details): - pass - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["finish_reason"], "stop") - - def test_record_overwrites_string_messages(self): - """record_output_messages with strings overwrites previous messages.""" - response = Response(messages=["Initial"]) - with OutputScope.start(Request(), response, self.agent_details) as scope: - scope.record_output_messages(["Replacement"]) - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 1) - self.assertNotIn("Initial", attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed[0]["parts"][0]["content"], "Replacement") - - def test_record_overwrites_with_structured(self): - """record_output_messages with OutputMessages overwrites previous messages.""" - response = Response(messages=["Initial"]) - replacement = OutputMessages( - messages=[ - OutputMessage( - role=MessageRole.ASSISTANT, - parts=[TextPart(content="Structured replacement")], - finish_reason=FinishReason.STOP.value, - ) - ] - ) - with OutputScope.start(Request(), response, self.agent_details) as scope: - scope.record_output_messages(replacement) - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 1) - self.assertEqual(parsed[0]["finish_reason"], "stop") - - def test_record_overwrites_with_dict(self): - """record_output_messages with dict sets tool result directly.""" - response = Response(messages=["Initial"]) - with OutputScope.start(Request(), response, self.agent_details) as scope: - scope.record_output_messages({"result": "tool output"}) - - attrs = self._get_last_span_attrs() - parsed = json.loads(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(parsed["result"], "tool output") - - def test_no_record_keeps_initial(self): - """If record_output_messages is not called, initial value remains.""" - response = Response(messages=["Only initial"]) - with OutputScope.start(Request(), response, self.agent_details): - pass - - attrs = self._get_last_span_attrs() - parsed = self._parse_messages(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertEqual(len(parsed), 1) - self.assertEqual(parsed[0]["parts"][0]["content"], "Only initial") - - -if __name__ == "__main__": - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_span_links.py b/tests/observability/core/test_span_links.py deleted file mode 100644 index 3bc58475..00000000 --- a/tests/observability/core/test_span_links.py +++ /dev/null @@ -1,215 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -import sys -import unittest -from pathlib import Path - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - ExecuteToolScope, - InferenceCallDetails, - InferenceOperationType, - InvokeAgentScope, - Request, - SpanDetails, - ToolCallDetails, - configure, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentScopeDetails -from microsoft_agents_a365.observability.core.models.response import Response -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import Link, SpanContext, TraceFlags - - -class TestSpanLinks(unittest.TestCase): - """Tests that span links are correctly forwarded to OTel spans on all scope types.""" - - @classmethod - def setUpClass(cls): - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - configure( - service_name="test-span-links-service", - service_namespace="test-namespace", - ) - - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent", - tenant_id="test-tenant-456", - ) - cls.test_request = Request(conversation_id="test-conv-123") - - cls.sample_links = [ - Link( - context=SpanContext( - trace_id=int("0aa4621e5ae09963a3de354f3d18aa65", 16), - span_id=int("c1aaa519600b1bf0", 16), - is_remote=True, - trace_flags=TraceFlags.SAMPLED, - ), - ), - Link( - context=SpanContext( - trace_id=int("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 16), - span_id=int("aaaaaaaaaaaaaaaa", 16), - is_remote=True, - trace_flags=TraceFlags.DEFAULT, - ), - attributes={"link.reason": "retry"}, - ), - ] - - def setUp(self): - super().setUp() - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - configure( - service_name="test-span-links-service", - service_namespace="test-namespace", - ) - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - self.span_exporter.clear() - - def _get_last_span(self): - spans = self.span_exporter.get_finished_spans() - self.assertTrue(spans, "Expected at least one span") - return spans[-1] - - def test_execute_tool_scope_records_span_links(self): - """Test span links are recorded on ExecuteToolScope spans.""" - tool_details = ToolCallDetails(tool_name="my-tool") - scope = ExecuteToolScope.start( - self.test_request, - tool_details, - self.agent_details, - span_details=SpanDetails(span_links=self.sample_links), - ) - scope.dispose() - - span = self._get_last_span() - self.assertEqual(len(span.links), 2) - self.assertEqual( - f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" - ) - self.assertEqual(f"{span.links[0].context.span_id:016x}", "c1aaa519600b1bf0") - self.assertEqual(span.links[0].context.trace_flags, TraceFlags.SAMPLED) - self.assertEqual( - f"{span.links[1].context.trace_id:032x}", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ) - self.assertEqual(span.links[1].attributes.get("link.reason"), "retry") - - def test_invoke_agent_scope_records_span_links(self): - """Test span links are recorded on InvokeAgentScope spans.""" - scope = InvokeAgentScope.start( - self.test_request, - InvokeAgentScopeDetails(), - self.agent_details, - span_details=SpanDetails(span_links=self.sample_links), - ) - scope.dispose() - - span = self._get_last_span() - self.assertEqual(len(span.links), 2) - self.assertEqual( - f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" - ) - - def test_inference_scope_records_span_links(self): - """Test span links are recorded on InferenceScope spans.""" - from microsoft_agents_a365.observability.core import InferenceScope - - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - scope = InferenceScope.start( - self.test_request, - details, - self.agent_details, - span_details=SpanDetails(span_links=self.sample_links), - ) - scope.dispose() - - span = self._get_last_span() - self.assertEqual(len(span.links), 2) - self.assertEqual( - f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" - ) - - def test_output_scope_records_span_links(self): - """Test span links are recorded on OutputScope spans.""" - response = Response(messages=["hello"]) - scope = OutputScope.start( - self.test_request, - response, - self.agent_details, - span_details=SpanDetails(span_links=self.sample_links), - ) - scope.dispose() - - span = self._get_last_span() - self.assertEqual(len(span.links), 2) - self.assertEqual( - f"{span.links[0].context.trace_id:032x}", "0aa4621e5ae09963a3de354f3d18aa65" - ) - - def test_no_span_links_when_omitted(self): - """Test spans have empty links when span_links is not provided.""" - tool_details = ToolCallDetails(tool_name="my-tool") - scope = ExecuteToolScope.start( - self.test_request, - tool_details, - self.agent_details, - ) - scope.dispose() - - span = self._get_last_span() - self.assertEqual(len(span.links), 0) - - def test_span_links_with_typed_attributes(self): - """Test span links preserve typed attributes (string, int).""" - links_with_attrs = [ - Link( - context=SpanContext( - trace_id=int("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 16), - span_id=int("bbbbbbbbbbbbbbbb", 16), - is_remote=True, - trace_flags=TraceFlags.SAMPLED, - ), - attributes={"link.type": "causal", "link.index": 0}, - ), - ] - - scope = InvokeAgentScope.start( - self.test_request, - InvokeAgentScopeDetails(), - self.agent_details, - span_details=SpanDetails(span_links=links_with_attrs), - ) - scope.dispose() - - span = self._get_last_span() - self.assertEqual(len(span.links), 1) - self.assertEqual(span.links[0].attributes.get("link.type"), "causal") - self.assertEqual(span.links[0].attributes.get("link.index"), 0) - - -if __name__ == "__main__": - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_span_processor.py b/tests/observability/core/test_span_processor.py deleted file mode 100644 index 1c91725a..00000000 --- a/tests/observability/core/test_span_processor.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from unittest.mock import MagicMock - -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_VERSION_KEY, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder -from microsoft_agents_a365.observability.core.trace_processor.span_processor import SpanProcessor -from opentelemetry import context - - -class TestSpanProcessor(unittest.TestCase): - def setUp(self): - # Clear any existing context/baggage before each test - context.attach({}) - - self.processor = SpanProcessor() - self.mock_span = MagicMock() - self.mock_context = None # Root span - - def test_baggage_propagates_to_span(self): - """Test that baggage values are propagated to span attributes.""" - # Mock span with no existing attributes - self.mock_span.attributes = {} - - # Set values in baggage using BaggageBuilder - with BaggageBuilder().tenant_id("test-tenant").agent_id("test-agent").build(): - # Call on_start - should propagate baggage values - self.processor.on_start(self.mock_span, context.get_current()) - - # Verify baggage values were set on the span - calls = self.mock_span.set_attribute.call_args_list - call_dict = {call[0][0]: call[0][1] for call in calls} - self.assertEqual(call_dict.get(TENANT_ID_KEY), "test-tenant") - self.assertEqual(call_dict.get(GEN_AI_AGENT_ID_KEY), "test-agent") - - def test_on_end_calls_super(self): - try: - self.processor.on_end(self.mock_span) - except Exception as e: - self.fail(f"on_end raised an exception: {e}") - - def test_agent_version_baggage_propagates_to_span(self): - """Test that agent version baggage is propagated to span attributes.""" - self.mock_span.attributes = {} - - with ( - BaggageBuilder() - .tenant_id("test-tenant") - .agent_id("test-agent") - .agent_version("3.0.0") - .build() - ): - self.processor.on_start(self.mock_span, context.get_current()) - - calls = self.mock_span.set_attribute.call_args_list - call_dict = {call[0][0]: call[0][1] for call in calls} - self.assertEqual(call_dict.get(GEN_AI_AGENT_VERSION_KEY), "3.0.0") - self.assertEqual(call_dict.get(TENANT_ID_KEY), "test-tenant") - self.assertEqual(call_dict.get(GEN_AI_AGENT_ID_KEY), "test-agent") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_spectra_exporter.py b/tests/observability/core/test_spectra_exporter.py deleted file mode 100644 index b454d61b..00000000 --- a/tests/observability/core/test_spectra_exporter.py +++ /dev/null @@ -1,294 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from unittest.mock import Mock, patch - -from microsoft_agents_a365.observability.core import configure -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, -) -from microsoft_agents_a365.observability.core.exporters.agent365_exporter_options import ( - Agent365ExporterOptions, -) -from microsoft_agents_a365.observability.core.exporters.enriched_span import ( - EnrichedReadableSpan, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from microsoft_agents_a365.observability.core.exporters.spectra_exporter_options import ( - SpectraExporterOptions, -) -from opentelemetry.sdk.trace import ReadableSpan - - -class TestSpectraExporterOptions(unittest.TestCase): - """Tests for SpectraExporterOptions class.""" - - def test_spectra_exporter_options_defaults(self): - """All default values are correct.""" - opts = SpectraExporterOptions() - self.assertEqual(opts.endpoint, "http://localhost:4317") - self.assertEqual(opts.protocol, "grpc") - self.assertTrue(opts.insecure) - self.assertEqual(opts.max_queue_size, 2048) - self.assertEqual(opts.scheduled_delay_ms, 5000) - self.assertEqual(opts.exporter_timeout_ms, 30000) - self.assertEqual(opts.max_export_batch_size, 512) - - def test_spectra_exporter_options_http_default_endpoint(self): - """HTTP protocol defaults to port 4318.""" - opts = SpectraExporterOptions(protocol="http") - self.assertEqual(opts.endpoint, "http://localhost:4318") - - def test_spectra_exporter_options_explicit_endpoint_overrides_default(self): - """Explicit endpoint overrides protocol-based default.""" - opts = SpectraExporterOptions(protocol="http", endpoint="http://custom:9999") - self.assertEqual(opts.endpoint, "http://custom:9999") - - def test_spectra_options_invalid_protocol_raises(self): - """ValueError for invalid protocol.""" - with self.assertRaises(ValueError) as ctx: - SpectraExporterOptions(protocol="websocket") - self.assertIn("websocket", str(ctx.exception)) - - -class TestConfigureWithSpectraOptions(unittest.TestCase): - """Tests for configure() with SpectraExporterOptions.""" - - def setUp(self): - from microsoft_agents_a365.observability.core.config import _telemetry_manager - from microsoft_agents_a365.observability.core.opentelemetry_scope import ( - OpenTelemetryScope, - ) - - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - def tearDown(self): - from microsoft_agents_a365.observability.core.config import _telemetry_manager - from microsoft_agents_a365.observability.core.opentelemetry_scope import ( - OpenTelemetryScope, - ) - - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - def test_configure_with_spectra_options_default(self): - """configure() succeeds with SpectraExporterOptions() defaults.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(), - ) - self.assertTrue(result) - - @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") - def test_configure_with_spectra_options_creates_grpc_exporter(self, mock_grpc): - """gRPC OTLPSpanExporter created with correct endpoint and insecure args.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(), - ) - self.assertTrue(result) - mock_grpc.assert_called_once_with( - endpoint="http://localhost:4317", - insecure=True, - ) - - @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") - def test_configure_with_spectra_options_creates_http_exporter(self, mock_http): - """HTTP OTLPSpanExporter created when protocol='http'.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(protocol="http"), - ) - self.assertTrue(result) - mock_http.assert_called_once_with( - endpoint="http://localhost:4318", - ) - - @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") - def test_configure_with_spectra_options_custom_endpoint(self, mock_grpc): - """Custom endpoint passed through to exporter.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(endpoint="http://spectra-sidecar:4317"), - ) - self.assertTrue(result) - mock_grpc.assert_called_once_with( - endpoint="http://spectra-sidecar:4317", - insecure=True, - ) - - @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") - @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") - @patch.dict("os.environ", {"ENABLE_A365_OBSERVABILITY_EXPORTER": "true"}) - def test_configure_with_spectra_options_ignores_a365_env_var(self, mock_is_enabled, mock_grpc): - """ENABLE_A365_OBSERVABILITY_EXPORTER=true doesn't create _Agent365Exporter.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(), - ) - self.assertTrue(result) - mock_grpc.assert_called_once() - # is_agent365_exporter_enabled should not be called when Spectra path is taken - mock_is_enabled.assert_not_called() - - @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") - @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") - def test_configure_with_spectra_options_batch_settings(self, mock_grpc, mock_batch): - """Batch processor kwargs extracted from SpectraExporterOptions.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions( - max_queue_size=1024, - scheduled_delay_ms=2000, - exporter_timeout_ms=15000, - max_export_batch_size=256, - ), - ) - self.assertTrue(result) - mock_batch.assert_called_once() - call_kwargs = mock_batch.call_args.kwargs - self.assertEqual(call_kwargs["max_queue_size"], 1024) - self.assertEqual(call_kwargs["schedule_delay_millis"], 2000) - self.assertEqual(call_kwargs["export_timeout_millis"], 15000) - self.assertEqual(call_kwargs["max_export_batch_size"], 256) - - @patch("microsoft_agents_a365.observability.core.config._Agent365Exporter") - @patch("microsoft_agents_a365.observability.core.config.is_agent365_exporter_enabled") - def test_configure_with_agent365_options_unchanged(self, mock_is_enabled, mock_exporter): - """A365 regression test — existing path still works identically.""" - mock_is_enabled.return_value = True - mock_token_resolver = Mock() - mock_token_resolver.return_value = "token" - - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=Agent365ExporterOptions( - cluster_category="staging", - token_resolver=mock_token_resolver, - use_s2s_endpoint=True, - ), - ) - self.assertTrue(result) - mock_exporter.assert_called_once_with( - token_resolver=mock_token_resolver, - cluster_category="staging", - use_s2s_endpoint=True, - max_payload_bytes=900_000, - ) - - @patch("microsoft_agents_a365.observability.core.config.OTLPSpanExporter") - @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") - @patch.dict("os.environ", {"ENABLE_OTLP_EXPORTER": "true"}) - def test_configure_spectra_with_otlp_bolt_on(self, mock_grpc, mock_http_otlp): - """Spectra + ENABLE_OTLP_EXPORTER=true creates two exporters.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(), - ) - self.assertTrue(result) - # gRPC for Spectra - mock_grpc.assert_called_once() - # HTTP OTLP for bolt-on - mock_http_otlp.assert_called_once() - - @patch("microsoft_agents_a365.observability.core.config._EnrichingBatchSpanProcessor") - @patch("microsoft_agents_a365.observability.core.config.GrpcOTLPSpanExporter") - def test_configure_spectra_with_suppress_invoke_agent_input(self, mock_grpc, mock_batch): - """suppress_invoke_agent_input=True passed to batch processor.""" - result = configure( - service_name="test-service", - service_namespace="test-namespace", - exporter_options=SpectraExporterOptions(), - suppress_invoke_agent_input=True, - ) - self.assertTrue(result) - mock_batch.assert_called_once() - call_kwargs = mock_batch.call_args.kwargs - self.assertTrue(call_kwargs["suppress_invoke_agent_input"]) - - -class TestEnrichedSpanExcludedAttributes(unittest.TestCase): - """Tests for EnrichedReadableSpan excluded_attribute_keys.""" - - def test_enriched_span_excluded_attribute_keys(self): - """EnrichedReadableSpan with exclusions removes specified attributes.""" - mock_span = Mock(spec=ReadableSpan) - mock_span.attributes = { - "key1": "value1", - "key2": "value2", - "key3": "value3", - } - - enriched = EnrichedReadableSpan( - mock_span, - extra_attributes={"key4": "value4"}, - excluded_attribute_keys={"key2"}, - ) - - attrs = enriched.attributes - self.assertEqual(attrs["key1"], "value1") - self.assertNotIn("key2", attrs) - self.assertEqual(attrs["key3"], "value3") - self.assertEqual(attrs["key4"], "value4") - - -class TestSuppressInvokeAgentInputInProcessor(unittest.TestCase): - """Tests for suppress_invoke_agent_input in _EnrichingBatchSpanProcessor.""" - - def test_suppress_invoke_agent_input_strips_attribute_in_enriching_processor(self): - """Processor strips gen_ai.input.messages from InvokeAgent spans.""" - mock_exporter = Mock() - - processor = _EnrichingBatchSpanProcessor( - mock_exporter, - suppress_invoke_agent_input=True, - ) - - mock_span = Mock(spec=ReadableSpan) - mock_span.name = "invoke_agent test-agent" - mock_span.attributes = { - GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, - GEN_AI_INPUT_MESSAGES_KEY: '[{"role": "user", "content": "hello"}]', - "other_key": "other_value", - } - - with patch.object(_EnrichingBatchSpanProcessor, "on_end", wraps=processor.on_end): - # Call on_end directly — the parent's on_end will queue the span - # We patch super().on_end to capture what gets queued - with patch( - "microsoft_agents_a365.observability.core.exporters" - ".enriching_span_processor.BatchSpanProcessor.on_end" - ) as mock_super_on_end: - processor.on_end(mock_span) - - # Verify super().on_end was called with an EnrichedReadableSpan - mock_super_on_end.assert_called_once() - enriched_span = mock_super_on_end.call_args[0][0] - self.assertIsInstance(enriched_span, EnrichedReadableSpan) - - # Verify input messages were stripped - attrs = enriched_span.attributes - self.assertNotIn(GEN_AI_INPUT_MESSAGES_KEY, attrs) - self.assertEqual(attrs["other_key"], "other_value") - - processor.shutdown() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/core/test_trace_context_propagation.py b/tests/observability/core/test_trace_context_propagation.py deleted file mode 100644 index 349452fb..00000000 --- a/tests/observability/core/test_trace_context_propagation.py +++ /dev/null @@ -1,284 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for trace context propagation functionality.""" - -import os -import unittest - -import pytest -from microsoft_agents_a365.observability.core import ( - AgentDetails, - ExecuteToolScope, - InferenceCallDetails, - InferenceOperationType, - InferenceScope, - InvokeAgentScope, - InvokeAgentScopeDetails, - Request, - ServiceEndpoint, - SpanDetails, - ToolCallDetails, - configure, - extract_context_from_headers, - get_tracer_provider, -) -from microsoft_agents_a365.observability.core.config import _telemetry_manager -from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - - -class TestTraceContextPropagation(unittest.TestCase): - """Unit tests for trace context propagation functionality.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - configure( - service_name="test-trace-propagation-service", - service_namespace="test-namespace", - ) - - cls.agent_details = AgentDetails( - agent_id="test-agent-123", - agent_name="Test Agent", - agent_description="A test agent for trace propagation testing", - ) - - def setUp(self): - super().setUp() - - _telemetry_manager._tracer_provider = None - _telemetry_manager._span_processors = {} - OpenTelemetryScope._tracer = None - - configure( - service_name="test-trace-propagation-service", - service_namespace="test-namespace", - ) - - self.span_exporter = InMemorySpanExporter() - tracer_provider = get_tracer_provider() - tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - - def tearDown(self): - super().tearDown() - self.span_exporter.clear() - - def test_inject_context_to_headers_returns_headers(self): - """Test that inject_context_to_headers returns traceparent and tracestate headers.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - headers = scope.inject_context_to_headers() - - # Should contain at least traceparent header - self.assertIn("traceparent", headers) - - # Validate traceparent format: 00-{trace_id}-{span_id}-{flags} - traceparent = headers["traceparent"] - parts = traceparent.split("-") - self.assertEqual(len(parts), 4, "traceparent should have 4 parts") - self.assertEqual(parts[0], "00", "version should be 00") - self.assertEqual(len(parts[1]), 32, "trace_id should be 32 hex chars") - self.assertEqual(len(parts[2]), 16, "span_id should be 16 hex chars") - self.assertEqual(len(parts[3]), 2, "flags should be 2 hex chars") - - scope.dispose() - - def test_inject_context_to_headers_contains_span_id(self): - """Test that injected headers contain the correct span ID.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - headers = scope.inject_context_to_headers() - scope.dispose() - - # Get the span from exported spans - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - expected_span_id = f"{span.context.span_id:016x}" - - # Verify the traceparent contains the span_id - traceparent = headers["traceparent"] - parts = traceparent.split("-") - self.assertEqual(parts[2], expected_span_id) - - def test_get_context_returns_context_object(self): - """Test that get_context returns a valid Context object.""" - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - ctx = scope.get_context() - - # Should return a Context object - self.assertIsNotNone(ctx) - - scope.dispose() - - def test_context_propagation_via_inject_extract(self): - """Test that context can be propagated using inject/extract pattern.""" - # Create parent scope - parent_details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - parent_scope = InferenceScope.start(Request(), parent_details, self.agent_details) - - # Get injected headers from parent - headers = parent_scope.inject_context_to_headers() - parent_scope.dispose() - - # Extract context from headers (simulating receiving via HTTP) - parent_context = extract_context_from_headers(headers) - - # Create child scope using extracted context - tool_details = ToolCallDetails( - tool_name="search_tool", - arguments='{"query": "test"}', - tool_call_id="call-123", - ) - - child_scope = ExecuteToolScope.start( - Request(), - tool_details, - self.agent_details, - span_details=SpanDetails(parent_context=parent_context), - ) - child_scope.dispose() - - # Verify parent-child relationship - finished_spans = self.span_exporter.get_finished_spans() - self.assertEqual(len(finished_spans), 2, "Expected 2 spans (parent and child)") - - parent_span = finished_spans[0] - child_span = finished_spans[1] - - # Child should have parent's trace_id - self.assertEqual( - parent_span.context.trace_id, - child_span.context.trace_id, - "Child span should have same trace_id as parent", - ) - - # Child's parent should be the parent span - self.assertIsNotNone(child_span.parent) - self.assertEqual( - child_span.parent.span_id, - parent_span.context.span_id, - "Child's parent_id should match parent's span_id", - ) - - def test_inject_headers_for_http_propagation(self): - """Test that injected headers can be used for HTTP request propagation.""" - # Create a scope - details = InferenceCallDetails( - operationName=InferenceOperationType.CHAT, - model="gpt-4", - providerName="openai", - ) - - scope = InferenceScope.start(Request(), details, self.agent_details) - - if scope is not None: - headers = scope.inject_context_to_headers() - scope.dispose() - - # Simulate receiving these headers and creating a child scope - parent_ctx = extract_context_from_headers(headers) - - # Create new scope using received context - child_details = InferenceCallDetails( - operationName=InferenceOperationType.TEXT_COMPLETION, - model="gpt-3.5-turbo", - providerName="openai", - ) - - child_scope = InferenceScope.start( - Request(), - child_details, - self.agent_details, - span_details=SpanDetails(parent_context=parent_ctx), - ) - if child_scope is not None: - child_scope.dispose() - - # Verify spans are properly linked - finished_spans = self.span_exporter.get_finished_spans() - self.assertEqual(len(finished_spans), 2) - - parent_span = finished_spans[0] - child_span = finished_spans[1] - - # Should have same trace_id - self.assertEqual( - f"{parent_span.context.trace_id:032x}", - f"{child_span.context.trace_id:032x}", - ) - - def test_invoke_agent_scope_with_parent_context(self): - """Test InvokeAgentScope with parent_context parameter.""" - parent_trace_id = "1234567890abcdef1234567890abcdef" - parent_span_id = "abcdefabcdef1234" - traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" - - parent_context = extract_context_from_headers({"traceparent": traceparent}) - - invoke_scope_details = InvokeAgentScopeDetails( - endpoint=ServiceEndpoint(hostname="example.com", port=443), - ) - - with InvokeAgentScope.start( - Request(), - invoke_scope_details, - self.agent_details, - span_details=SpanDetails(parent_context=parent_context), - ) as scope: - headers = scope.inject_context_to_headers() - self.assertIn("traceparent", headers) - - finished_spans = self.span_exporter.get_finished_spans() - self.assertTrue(finished_spans, "Expected at least one span to be created") - - span = finished_spans[-1] - - # Verify span inherits parent's trace_id - span_trace_id = f"{span.context.trace_id:032x}" - self.assertEqual(span_trace_id, parent_trace_id) - - # Verify span's parent_span_id matches - self.assertIsNotNone(span.parent, "Expected span to have a parent") - span_parent_id = f"{span.parent.span_id:016x}" - self.assertEqual(span_parent_id, parent_span_id) - - -if __name__ == "__main__": - import sys - from pathlib import Path - - sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/core/test_utils.py b/tests/observability/core/test_utils.py deleted file mode 100644 index b82d6d71..00000000 --- a/tests/observability/core/test_utils.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest - -from microsoft_agents_a365.observability.core.utils import validate_and_normalize_ip - - -class TestUtils(unittest.TestCase): - """Unit tests for utility functions.""" - - def test_validate_and_normalize_ip(self): - """Test validate_and_normalize_ip with various IP address scenarios.""" - # Valid IPv4 and IPv6 addresses - self.assertEqual(validate_and_normalize_ip("192.168.1.1"), "192.168.1.1") - self.assertEqual(validate_and_normalize_ip("2001:db8::1"), "2001:db8::1") - - # IPv6 normalization - self.assertEqual( - validate_and_normalize_ip("2001:0db8:0000:0000:0000:0000:0000:0001"), "2001:db8::1" - ) - - # Invalid IP addresses and edge cases - self.assertIsNone(validate_and_normalize_ip("256.1.1.1")) - self.assertIsNone(validate_and_normalize_ip("not.an.ip.address")) - self.assertIsNone(validate_and_normalize_ip("2001:db8::g1")) - self.assertIsNone(validate_and_normalize_ip(None)) - self.assertIsNone(validate_and_normalize_ip("")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/extensions/__init__.py b/tests/observability/extensions/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/tests/observability/extensions/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/extensions/agentframework/__init__.py b/tests/observability/extensions/agentframework/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/tests/observability/extensions/agentframework/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/extensions/agentframework/integration/__init__.py b/tests/observability/extensions/agentframework/integration/__init__.py deleted file mode 100644 index 37373b78..00000000 --- a/tests/observability/extensions/agentframework/integration/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Integration tests for Microsoft Agent 365 Python SDK.""" diff --git a/tests/observability/extensions/agentframework/integration/conftest.py b/tests/observability/extensions/agentframework/integration/conftest.py deleted file mode 100644 index 7085a997..00000000 --- a/tests/observability/extensions/agentframework/integration/conftest.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -from pathlib import Path -from typing import Any - -import pytest - -# Load .env file if it exists (for local development) -try: - from dotenv import load_dotenv - - # Look for .env file in tests directory - # Navigate from conftest.py location: integration -> openai -> extensions -> observability -> tests - current_file = Path(__file__) - tests_dir = current_file.parent.parent.parent.parent.parent # Go up to tests/ directory - env_file = tests_dir / ".env" - if env_file.exists(): - load_dotenv(env_file) -except ImportError: - # python-dotenv not installed, skip loading .env file - pass - - -def pytest_configure(config): - """Add integration marker.""" - config.addinivalue_line("markers", "integration: marks tests as integration tests") - - -@pytest.fixture(scope="session") -def azure_openai_config() -> dict[str, Any]: - """Azure OpenAI configuration for integration tests.""" - api_key = os.getenv("AZURE_OPENAI_API_KEY") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4") - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview") - - if not api_key or not endpoint: - pytest.skip("Integration tests require AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT") - - return { - "api_key": api_key, - "endpoint": endpoint, - "deployment": deployment, - "api_version": api_version, - } - - -@pytest.fixture(scope="session") -def agent365_config() -> dict[str, Any]: - """Microsoft Agent 365 configuration for integration tests.""" - tenant_id = os.getenv("AGENT365_TEST_TENANT_ID", "4d44f041-f91e-4d00-b107-61e47b26f5a8") - agent_id = os.getenv("AGENT365_TEST_AGENT_ID", "3bccd52b-daaa-4b11-af40-47443852137c") - - if not tenant_id: - pytest.skip("Integration tests require AGENT365_TEST_TENANT_ID") - - return {"tenant_id": tenant_id, "agent_id": agent_id} diff --git a/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py b/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py deleted file mode 100644 index d7ff95e1..00000000 --- a/tests/observability/extensions/agentframework/integration/test_agentframework_trace_processor.py +++ /dev/null @@ -1,300 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import time - -import pytest -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.extensions.agentframework.trace_instrumentor import ( - AgentFrameworkInstrumentor, -) - -# AgentFramework SDK -try: - from agent_framework import RawAgent, ai_function - from agent_framework.azure import AzureOpenAIChatClient - from agent_framework.observability import setup_observability - from azure.identity import AzureCliCredential -except ImportError: - pytest.skip( - "AgentFramework library and dependencies required for integration tests", - allow_module_level=True, - ) - - -@ai_function -def add_numbers(a: float, b: float) -> float: - """Add two numbers together. - Args: - a: First number - b: Second number - Returns: - The sum of a and b - """ - return a + b - - -@pytest.mark.integration -class TestAgentFrameworkTraceProcessorIntegration: - """Integration tests for AgentFramework trace processor with real Azure OpenAI.""" - - def setup_method(self): - """Set up test method with mock exporter.""" - self.captured_spans = [] - self.mock_exporter = MockAgent365Exporter(self.captured_spans) - - def test_agentframework_trace_processor_integration(self, azure_openai_config, agent365_config): - """Test AgentFramework trace processor with real Azure OpenAI call.""" - - # Configure observability - configure( - service_name="integration-test-service", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - # Get the tracer provider and add our mock exporter - provider = get_tracer_provider() - provider.add_span_processor(self.mock_exporter) - - setup_observability() - - # Initialize the instrumentor - instrumentor = AgentFrameworkInstrumentor() - instrumentor.instrument() - - try: - # Create Azure OpenAI ChatClient - chat_client = AzureOpenAIChatClient( - endpoint=azure_openai_config["endpoint"], - credential=AzureCliCredential(), - deployment_name=azure_openai_config["deployment"], - api_version=azure_openai_config["api_version"], - ) - - # Create agent framework agent - agent = RawAgent( - client=chat_client, - instructions="You are a helpful assistant.", - tools=[], - ) - - # Execute a simple prompt using async runner - import asyncio - - async def run_agent(): - result = await agent.run("What can you do with agent framework?") - return result - - response = asyncio.run(run_agent()) - print(f"Agent response: {response}") - # Give some time for spans to be processed - time.sleep(1) - - # Verify that spans were captured - assert len(self.captured_spans) > 0, "No spans were captured" - - # Verify we have the expected span types - span_names = [span.name for span in self.captured_spans] - print(f"Captured spans: {span_names}") - - # Validate attributes on spans - self._validate_span_attributes(agent365_config) - - # Verify the response content - assert response is not None - assert len(response.text) > 0 - print(f"Agent response: {response.text}") - - finally: - # Clean up - instrumentor.uninstrument() - - def test_agentframework_trace_processor_with_tool_calls( - self, azure_openai_config, agent365_config - ): - """Test AgentFramework trace processor with tool calls.""" - - # Configure observability - configure( - service_name="integration-test-service-tools", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - # Get the tracer provider and add our mock exporter - provider = get_tracer_provider() - provider.add_span_processor(self.mock_exporter) - - setup_observability() - - # Initialize the instrumentor - instrumentor = AgentFrameworkInstrumentor() - instrumentor.instrument() - - try: - # Create Azure OpenAI ChatClient - chat_client = AzureOpenAIChatClient( - endpoint=azure_openai_config["endpoint"], - credential=AzureCliCredential(), - deployment_name=azure_openai_config["deployment"], - api_version=azure_openai_config["api_version"], - ) - - # Create agent framework agent - agent = RawAgent( - client=chat_client, - instructions="You are a helpful agent framework assistant.", - tools=[add_numbers], - ) - - # Execute a prompt that requires tool usage - import asyncio - - async def run_agent_with_tool(): - result = await agent.run("What is 15 + 27?") - return result - - response = asyncio.run(run_agent_with_tool()) - - # Give some time for spans to be processed - time.sleep(1) - - # Verify that spans were captured - assert len(self.captured_spans) > 0, "No spans were captured" - - # Verify we have the expected span types - span_names = [span.name for span in self.captured_spans] - print(f"Captured spans with tools: {span_names}") - - # Validate attributes on spans including tool calls - self._validate_tool_span_attributes(agent365_config) - - # Verify the response content includes the calculation result - assert response is not None - assert len(response.text) > 0 - assert "42" in response.text # 15 + 27 = 42 - print(f"Agent response with tool: {response.text}") - - finally: - # Clean up - instrumentor.uninstrument() - - def _validate_span_attributes(self, agent365_config): - """Validate that spans have the expected attributes.""" - llm_spans_found = 0 - agent_spans_found = 0 - - for span in self.captured_spans: - attributes = dict(span.attributes or {}) - print(f"Span '{span.name}' attributes: {list(attributes.keys())}") - - # Check common attributes - if TENANT_ID_KEY in attributes: - assert attributes[TENANT_ID_KEY] == agent365_config["tenant_id"] - - # Check for LLM spans (generation spans) - if ( - GEN_AI_PROVIDER_NAME_KEY in attributes - and attributes[GEN_AI_PROVIDER_NAME_KEY] == "openai" - ): - if GEN_AI_REQUEST_MODEL_KEY in attributes: - llm_spans_found += 1 - # Validate LLM span attributes - assert GEN_AI_REQUEST_MODEL_KEY in attributes - assert attributes[GEN_AI_REQUEST_MODEL_KEY] is not None - print(f"✓ Found LLM span with model: {attributes[GEN_AI_REQUEST_MODEL_KEY]}") - - # Check for input/output messages - if GEN_AI_INPUT_MESSAGES_KEY in attributes: - input_messages = attributes[GEN_AI_INPUT_MESSAGES_KEY] - assert input_messages is not None - print(f"✓ Input messages found: {input_messages[:100]}...") - - if GEN_AI_OUTPUT_MESSAGES_KEY in attributes: - output_messages = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - assert output_messages is not None - print(f"✓ Output messages found: {output_messages[:100]}...") - - # Check for agent spans - if "agent" in span.name.lower(): - agent_spans_found += 1 - print(f"✓ Found agent span: {span.name}") - - # Ensure we found at least some spans with telemetry data - assert len(self.captured_spans) > 0, "No spans were captured" - print(f"✓ Captured {len(self.captured_spans)} spans total") - print(f"✓ Found {llm_spans_found} LLM spans and {agent_spans_found} agent spans") - - def _validate_tool_span_attributes(self, agent365_config): - """Validate that spans have the expected attributes including tool calls.""" - llm_spans_found = 0 - agent_spans_found = 0 - tool_spans_found = 0 - - for span in self.captured_spans: - attributes = dict(span.attributes or {}) - print(f"Span '{span.name}' attributes: {list(attributes.keys())}") - - # Check common attributes - if TENANT_ID_KEY in attributes: - assert attributes[TENANT_ID_KEY] == agent365_config["tenant_id"] - - # Check for LLM spans - if "chat" in span.name.lower(): - if GEN_AI_REQUEST_MODEL_KEY in attributes: - llm_spans_found += 1 - print(f"✓ Found LLM span with model: {attributes[GEN_AI_REQUEST_MODEL_KEY]}") - - # Check for tool calls in messages - if GEN_AI_OUTPUT_MESSAGES_KEY in attributes: - output_messages = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - if "tool_calls" in output_messages: - print("✓ Found tool calls in LLM output messages") - - # Check for agent spans - if "agent" in span.name.lower(): - agent_spans_found += 1 - print(f"✓ Found agent span: {span.name}") - - # Check for tool execution spans - if "execute_tool" in span.name.lower() or "add_numbers" in span.name.lower(): - tool_spans_found += 1 - print(f"✓ Found tool execution span: {span.name}") - - # Ensure we found the expected span types - assert len(self.captured_spans) > 0, "No spans were captured" - print(f"✓ Captured {len(self.captured_spans)} spans total") - print( - f"✓ Found {llm_spans_found} LLM spans, {agent_spans_found} agent spans, and {tool_spans_found} tool spans" - ) - - -class MockAgent365Exporter: - """Mock span processor that captures spans instead of sending them.""" - - def __init__(self, captured_spans): - self.captured_spans = captured_spans - - def on_start(self, span, parent_context=None): - """Called when a span starts.""" - pass - - def on_end(self, span): - """Called when a span ends.""" - self.captured_spans.append(span) - - def shutdown(self): - """Mock shutdown.""" - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - """Mock force flush.""" - return True diff --git a/tests/observability/extensions/agentframework/integration/test_message_format.py b/tests/observability/extensions/agentframework/integration/test_message_format.py deleted file mode 100644 index 9182fe48..00000000 --- a/tests/observability/extensions/agentframework/integration/test_message_format.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Integration tests for AgentFramework message format mapping. - -These tests use the real A365 observability pipeline: - configure() → get_tracer_provider() → AgentFrameworkInstrumentor -with a SpanCapturingExporter inside _EnrichingBatchSpanProcessor, so spans -are captured after the enricher has run. This exercises the full code path: - auto-instrumentation → enricher → mapper → serialize → export. -""" - -import json -import time -from typing import Any - -import pytest -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult - -# AgentFramework SDK -try: - from agent_framework import RawAgent, tool - from agent_framework.azure import AzureOpenAIChatClient - from agent_framework.observability import enable_instrumentation - from azure.identity import AzureCliCredential -except ImportError: - pytest.skip( - "AgentFramework library and dependencies required for integration tests", - allow_module_level=True, - ) - -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from microsoft_agents_a365.observability.extensions.agentframework import ( - AgentFrameworkInstrumentor, -) - - -@tool -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The city name to get weather for. - - Returns: - A string describing the weather. - """ - return f"The weather in {city} is sunny, 22°C." - - -class SpanCapturingExporter(SpanExporter): - """Exporter that collects enriched spans in-memory. - - When used inside _EnrichingBatchSpanProcessor, spans arrive here - after the registered enricher has already run. - """ - - def __init__(self) -> None: - self.spans: list[ReadableSpan] = [] - - def export(self, spans: list[ReadableSpan]) -> SpanExportResult: - self.spans.extend(spans) - return SpanExportResult.SUCCESS - - def shutdown(self) -> None: - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - return True - - -@pytest.mark.integration -class TestAgentFrameworkMessageFormat: - """Capture real AgentFramework span attributes after enrichment - and verify the A365 structured array message format.""" - - @pytest.fixture(autouse=True) - def setup_observability(self) -> None: - """Set up A365 observability with AgentFrameworkInstrumentor. - - A SpanCapturingExporter is attached via _EnrichingBatchSpanProcessor - so that spans are captured after the enricher has run. - """ - if not hasattr(TestAgentFrameworkMessageFormat, "_exporter"): - configure( - service_name="integration-test-message-format", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - exporter = SpanCapturingExporter() - provider = get_tracer_provider() - provider.add_span_processor( - _EnrichingBatchSpanProcessor( - exporter, - max_queue_size=100, - schedule_delay_millis=100, - max_export_batch_size=100, - ) - ) - - enable_instrumentation(enable_sensitive_data=True) - - instrumentor = AgentFrameworkInstrumentor() - instrumentor.instrument() - - TestAgentFrameworkMessageFormat._exporter = exporter - TestAgentFrameworkMessageFormat._instrumentor = instrumentor - - self.exporter = TestAgentFrameworkMessageFormat._exporter - self.exporter.spans.clear() - - @pytest.fixture - def chat_client(self, azure_openai_config: dict[str, Any]) -> AzureOpenAIChatClient: - """Create a real Azure OpenAI chat client.""" - return AzureOpenAIChatClient( - endpoint=azure_openai_config["endpoint"], - credential=AzureCliCredential(), - deployment_name=azure_openai_config["deployment"], - api_version=azure_openai_config["api_version"], - ) - - def _find_chat_spans(self) -> list[ReadableSpan]: - """Find exported spans that have gen_ai.input.messages. - - Forces a flush so batched spans are exported before inspection. - """ - get_tracer_provider().force_flush() - time.sleep(0.5) - return [ - s - for s in self.exporter.spans - if s.attributes and GEN_AI_INPUT_MESSAGES_KEY in s.attributes - ] - - @pytest.mark.asyncio - async def test_simple_chat_message_mapping(self, chat_client: AzureOpenAIChatClient) -> None: - """Simple chat: verify exported spans contain structured A365 messages - after enrichment (no manual mapper call).""" - agent = RawAgent( - client=chat_client, - instructions="You are a helpful assistant. Reply in one sentence.", - tools=[], - ) - - result = await agent.run("What is the capital of France?") - assert result is not None - assert len(result.text) > 0 - - chat_spans = self._find_chat_spans() - assert len(chat_spans) > 0, ( - f"No chat spans found. All spans: {[s.name for s in self.exporter.spans]}" - ) - - attrs = dict(chat_spans[-1].attributes or {}) - - # --- Input messages: enriched to structured array format --- - input_data = json.loads(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - assert isinstance(input_data, list) - messages = input_data - - roles = [m["role"] for m in messages] - assert "system" in roles - assert "user" in roles - for msg in messages: - for part in msg["parts"]: - assert "type" in part - - # --- Output messages: enriched to structured array format --- - output_data = json.loads(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - assert isinstance(output_data, list) - out_messages = output_data - - assert out_messages[0]["role"] == "assistant" - assert any(p["type"] == "text" for p in out_messages[0]["parts"]) - - print(f"\n=== Enriched input ===\n{json.dumps(input_data, indent=2)}") - print(f"\n=== Enriched output ===\n{json.dumps(output_data, indent=2)}") - - @pytest.mark.asyncio - async def test_tool_call_message_mapping(self, chat_client: AzureOpenAIChatClient) -> None: - """Tool-calling chat: verify tool_call and tool_call_response parts - survive enrichment in exported spans.""" - agent = RawAgent( - client=chat_client, - instructions="You are a weather assistant. Always use the get_weather function.", - tools=[get_weather], - ) - - result = await agent.run("What's the weather in Seattle?") - assert result is not None - assert len(result.text) > 0 - - chat_spans = self._find_chat_spans() - assert len(chat_spans) > 0 - - print(f"\n=== All exported spans ({len(self.exporter.spans)}) ===") - for s in self.exporter.spans: - op = (s.attributes or {}).get(GEN_AI_OPERATION_NAME_KEY, "(none)") - print(f" {s.name} | op={op}") - - # Collect part types from exported (enriched) spans - part_types: set[str] = set() - for span in chat_spans: - attrs = dict(span.attributes or {}) - for key in (GEN_AI_INPUT_MESSAGES_KEY, GEN_AI_OUTPUT_MESSAGES_KEY): - raw = attrs.get(key) - if not raw: - continue - data = json.loads(raw) - if isinstance(data, list): - messages = data - for msg in messages: - for part in msg.get("parts", []): - part_types.add(part.get("type", "")) - - assert "tool_call" in part_types, f"Expected tool_call in exported parts: {part_types}" - assert "tool_call_response" in part_types, ( - f"Expected tool_call_response in exported parts: {part_types}" - ) - print(f"\n Exported part types: {part_types}") diff --git a/tests/observability/extensions/agentframework/integration/test_observability_pipeline.py b/tests/observability/extensions/agentframework/integration/test_observability_pipeline.py deleted file mode 100644 index d4df94d8..00000000 --- a/tests/observability/extensions/agentframework/integration/test_observability_pipeline.py +++ /dev/null @@ -1,419 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""End-to-end pipeline integration tests for AgentFramework observability. - -These tests verify the full A365 observability pipeline: - InvokeAgentScope → InferenceScope (auto-instrumented) → ExecuteToolScope (auto-instrumented) - -All three scope types must appear in a single trace with correct parent-child -relationships and A365 message format attributes. -""" - -import json -import os -import time - -import pytest -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult - -try: - from agent_framework import RawAgent - from agent_framework import tool as ai_function - from agent_framework.azure import AzureOpenAIChatClient - from agent_framework.observability import enable_instrumentation - from azure.identity import AzureCliCredential -except ImportError: - pytest.skip( - "AgentFramework library and dependencies required for integration tests", - allow_module_level=True, - ) - -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.agent_details import AgentDetails -from microsoft_agents_a365.observability.core.constants import ( - EXECUTE_TOOL_OPERATION_NAME, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_TOOL_NAME_KEY, - INVOKE_AGENT_OPERATION_NAME, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentScopeDetails -from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope -from microsoft_agents_a365.observability.core.request import Request -from microsoft_agents_a365.observability.extensions.agentframework.trace_instrumentor import ( - AgentFrameworkInstrumentor, -) - -# Ensure A365 observability scopes are active for tests -os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - -@ai_function -def add_numbers(a: float, b: float) -> float: - """Add two numbers together. - - Args: - a: First number - b: Second number - - Returns: - The sum of a and b - """ - return a + b - - -class SpanCapturingExporter(SpanExporter): - """Exporter that collects spans in-memory after enrichment.""" - - def __init__(self) -> None: - self.spans: list[ReadableSpan] = [] - - def export(self, spans: list[ReadableSpan]) -> SpanExportResult: - self.spans.extend(spans) - return SpanExportResult.SUCCESS - - def shutdown(self) -> None: - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - return True - - -def _get_span_attr(span: ReadableSpan, key: str) -> str | None: - """Safely get an attribute from a span.""" - attrs = span.attributes or {} - return attrs.get(key) - - -def _find_spans_by_operation(spans: list[ReadableSpan], operation_name: str) -> list[ReadableSpan]: - """Find spans matching a given gen_ai.operation.name.""" - return [s for s in spans if _get_span_attr(s, GEN_AI_OPERATION_NAME_KEY) == operation_name] - - -def _find_spans_by_name_prefix(spans: list[ReadableSpan], prefix: str) -> list[ReadableSpan]: - """Find spans whose name starts with a given prefix.""" - return [s for s in spans if s.name.startswith(prefix)] - - -@pytest.mark.integration -class TestAgentFrameworkObservabilityPipeline: - """End-to-end pipeline tests: InvokeAgent → Inference → ToolExecution. - - Verifies that wrapping an AgentFramework call inside InvokeAgentScope - produces a single trace with correct parent-child span hierarchy, - operation names, and A365 message format attributes. - """ - - @pytest.fixture(autouse=True) - def setup_observability(self) -> None: - """Set up A365 observability with AgentFrameworkInstrumentor.""" - if not hasattr(TestAgentFrameworkObservabilityPipeline, "_exporter"): - configure( - service_name="integration-test-pipeline", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - exporter = SpanCapturingExporter() - provider = get_tracer_provider() - provider.add_span_processor( - _EnrichingBatchSpanProcessor( - exporter, - max_queue_size=100, - schedule_delay_millis=100, - max_export_batch_size=100, - ) - ) - - enable_instrumentation(enable_sensitive_data=True) - - instrumentor = AgentFrameworkInstrumentor() - instrumentor.instrument() - - TestAgentFrameworkObservabilityPipeline._exporter = exporter - TestAgentFrameworkObservabilityPipeline._instrumentor = instrumentor - - self.exporter = TestAgentFrameworkObservabilityPipeline._exporter - self.exporter.spans.clear() - - @pytest.fixture - def chat_client(self, azure_openai_config: dict) -> AzureOpenAIChatClient: - """Create a real Azure OpenAI chat client.""" - return AzureOpenAIChatClient( - endpoint=azure_openai_config["endpoint"], - credential=AzureCliCredential(), - deployment_name=azure_openai_config["deployment"], - api_version=azure_openai_config["api_version"], - ) - - @pytest.fixture - def agent_details(self, agent365_config: dict) -> AgentDetails: - """Create AgentDetails for the test agent.""" - return AgentDetails( - agent_id=agent365_config["agent_id"], - agent_name="pipeline-test-agent", - agent_description="Integration test agent for pipeline verification", - tenant_id=agent365_config["tenant_id"], - ) - - def _flush_and_collect(self) -> list[ReadableSpan]: - """Force flush and return all captured spans.""" - get_tracer_provider().force_flush() - time.sleep(0.5) - return list(self.exporter.spans) - - # ------------------------------------------------------------------ - # Test: Full pipeline with tool call - # ------------------------------------------------------------------ - - @pytest.mark.asyncio - async def test_pipeline_invoke_agent_with_tool_call( - self, - chat_client: AzureOpenAIChatClient, - agent_details: AgentDetails, - agent365_config: dict, - ) -> None: - """Full pipeline: InvokeAgentScope wraps AgentFramework with tool. - - Verifies: - 1. All spans share the same trace_id - 2. invoke_agent span is the root (no parent) - 3. Inference (chat) spans are descendants of invoke_agent - 4. Tool execution spans are descendants of invoke_agent - 5. A365 message format on chat spans (structured array format) - 6. Correct operation names and key attributes - """ - request = Request(content="What is 15 + 27?", session_id="test-session-pipeline") - - agent = RawAgent( - client=chat_client, - instructions=( - "You are a math assistant. You MUST use the add_numbers function " - "for any arithmetic. Never compute in your head." - ), - tools=[add_numbers], - ) - - with InvokeAgentScope.start( - request=request, - scope_details=InvokeAgentScopeDetails(), - agent_details=agent_details, - ): - result = await agent.run("What is 15 + 27?") - - assert result is not None - assert len(result.text) > 0 - assert "42" in result.text, f"Expected '42' in response: {result.text}" - - spans = self._flush_and_collect() - assert len(spans) > 0, "No spans were captured" - - # --- Print span tree for debugging --- - print(f"\n=== Captured {len(spans)} spans ===") - for s in spans: - op = _get_span_attr(s, GEN_AI_OPERATION_NAME_KEY) or "(none)" - parent_id = f"{s.parent.span_id:016x}" if s.parent else "None" - print( - f" {s.name} | op={op} | trace={s.context.trace_id:032x} " - f"| span={s.context.span_id:016x} | parent={parent_id}" - ) - - # --- 1. All spans share the same trace_id --- - invoke_spans = _find_spans_by_name_prefix(spans, "invoke_agent") - assert len(invoke_spans) >= 1, ( - f"Expected at least 1 invoke_agent span, got: {[s.name for s in spans]}" - ) - invoke_span = invoke_spans[0] - trace_id = invoke_span.context.trace_id - - for s in spans: - assert s.context.trace_id == trace_id, ( - f"Span '{s.name}' has different trace_id: " - f"{s.context.trace_id:032x} vs {trace_id:032x}" - ) - - # --- 2. invoke_agent span is the root (no parent) --- - assert invoke_span.parent is None, ( - f"invoke_agent span should be root but has parent: {invoke_span.parent.span_id:016x}" - ) - - # --- 3. invoke_agent has correct operation name --- - assert _get_span_attr(invoke_span, GEN_AI_OPERATION_NAME_KEY) == INVOKE_AGENT_OPERATION_NAME - - # --- 4. Tenant ID is set --- - assert _get_span_attr(invoke_span, TENANT_ID_KEY) == agent365_config["tenant_id"] - - # --- 5. Chat (inference) spans are descendants of invoke_agent --- - chat_spans = [ - s - for s in spans - if _get_span_attr(s, GEN_AI_OPERATION_NAME_KEY) == "chat" - or (s.name.startswith("chat") and _get_span_attr(s, GEN_AI_REQUEST_MODEL_KEY)) - ] - assert len(chat_spans) >= 1, ( - f"Expected at least 1 chat span, got: {[s.name for s in spans]}" - ) - - invoke_span_id = invoke_span.context.span_id - for chat_span in chat_spans: - assert chat_span.parent is not None, ( - f"Chat span '{chat_span.name}' should have a parent" - ) - # Chat span should be a child of invoke_agent (directly or transitively) - self._assert_ancestor( - chat_span, - invoke_span_id, - spans, - f"Chat span '{chat_span.name}' is not a descendant of invoke_agent", - ) - - # --- 6. Tool execution spans are descendants of invoke_agent --- - tool_spans = _find_spans_by_name_prefix(spans, "execute_tool") - if not tool_spans: - # Also check by operation name - tool_spans = _find_spans_by_operation(spans, EXECUTE_TOOL_OPERATION_NAME) - - assert len(tool_spans) >= 1, ( - f"Expected at least 1 execute_tool span. All spans: {[s.name for s in spans]}" - ) - for tool_span in tool_spans: - assert tool_span.parent is not None, ( - f"Tool span '{tool_span.name}' should have a parent" - ) - self._assert_ancestor( - tool_span, - invoke_span_id, - spans, - f"Tool span '{tool_span.name}' is not a descendant of invoke_agent", - ) - - # --- 7. A365 message format on chat spans --- - for chat_span in chat_spans: - attrs = dict(chat_span.attributes or {}) - if GEN_AI_INPUT_MESSAGES_KEY in attrs: - input_data = json.loads(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - if isinstance(input_data, list): - for msg in input_data: - assert "role" in msg - assert "parts" in msg - - if GEN_AI_OUTPUT_MESSAGES_KEY in attrs: - output_data = json.loads(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - if isinstance(output_data, list): - for msg in output_data: - assert "role" in msg - assert "parts" in msg - - # --- 8. Tool spans have tool-specific attributes --- - for tool_span in tool_spans: - attrs = dict(tool_span.attributes or {}) - op = attrs.get(GEN_AI_OPERATION_NAME_KEY, "") - if op == EXECUTE_TOOL_OPERATION_NAME or tool_span.name.startswith("execute_tool"): - assert GEN_AI_TOOL_NAME_KEY in attrs or "add_numbers" in tool_span.name, ( - f"Tool span missing tool name attribute: {list(attrs.keys())}" - ) - - print("\n✓ All pipeline assertions passed") - - # ------------------------------------------------------------------ - # Test: Pipeline without tools (simple inference only) - # ------------------------------------------------------------------ - - @pytest.mark.asyncio - async def test_pipeline_invoke_agent_simple_inference( - self, - chat_client: AzureOpenAIChatClient, - agent_details: AgentDetails, - agent365_config: dict, - ) -> None: - """Pipeline with InvokeAgentScope + simple inference (no tools). - - Verifies invoke_agent → chat span hierarchy without tool calls. - """ - request = Request(content="Say hello", session_id="test-session-simple") - - agent = RawAgent( - client=chat_client, - instructions="You are a helpful assistant. Reply in one sentence.", - tools=[], - ) - - with InvokeAgentScope.start( - request=request, - scope_details=InvokeAgentScopeDetails(), - agent_details=agent_details, - ): - result = await agent.run("Say hello in exactly 5 words.") - - assert result is not None - assert len(result.text) > 0 - - spans = self._flush_and_collect() - assert len(spans) > 0, "No spans were captured" - - # All spans share the same trace_id - invoke_spans = _find_spans_by_name_prefix(spans, "invoke_agent") - assert len(invoke_spans) >= 1 - invoke_span = invoke_spans[0] - trace_id = invoke_span.context.trace_id - - for s in spans: - assert s.context.trace_id == trace_id - - # invoke_agent is root - assert invoke_span.parent is None - - # Chat spans are descendants - chat_spans = [ - s - for s in spans - if _get_span_attr(s, GEN_AI_OPERATION_NAME_KEY) == "chat" - or (s.name.startswith("chat") and _get_span_attr(s, GEN_AI_REQUEST_MODEL_KEY)) - ] - assert len(chat_spans) >= 1 - - invoke_span_id = invoke_span.context.span_id - for chat_span in chat_spans: - self._assert_ancestor( - chat_span, - invoke_span_id, - spans, - f"Chat span '{chat_span.name}' not a descendant of invoke_agent", - ) - - print(f"\n✓ Simple pipeline: {len(spans)} spans, hierarchy verified") - - # ------------------------------------------------------------------ - # Helper: assert ancestor relationship - # ------------------------------------------------------------------ - - def _assert_ancestor( - self, - span: ReadableSpan, - ancestor_span_id: int, - all_spans: list[ReadableSpan], - message: str, - ) -> None: - """Walk up the parent chain and assert that ancestor_span_id is found.""" - span_map = {s.context.span_id: s for s in all_spans} - current = span - visited: set[int] = set() - while current.parent is not None: - parent_id = current.parent.span_id - if parent_id == ancestor_span_id: - return - if parent_id in visited: - break - visited.add(parent_id) - current = span_map.get(parent_id) - if current is None: - break - raise AssertionError(message) diff --git a/tests/observability/extensions/agentframework/test_span_enricher.py b/tests/observability/extensions/agentframework/test_span_enricher.py deleted file mode 100644 index 5b189ce3..00000000 --- a/tests/observability/extensions/agentframework/test_span_enricher.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for Agent Framework span enricher.""" - -import json -import unittest -from unittest.mock import Mock - -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_TOOL_ARGS_KEY, - GEN_AI_TOOL_CALL_RESULT_KEY, -) -from microsoft_agents_a365.observability.extensions.agentframework.span_enricher import ( - AF_TOOL_CALL_ARGUMENTS_KEY, - AF_TOOL_CALL_RESULT_KEY, - enrich_agent_framework_span, -) - - -class TestAgentFrameworkSpanEnricher(unittest.TestCase): - """Test suite for enrich_agent_framework_span function.""" - - def test_invoke_agent_span_enrichment(self): - """Test invoke_agent span maps messages to A365 versioned format.""" - span = Mock( - name="invoke_agent Agent365Assistant", - attributes={ - GEN_AI_INPUT_MESSAGES_KEY: '[{"role": "user", "parts": [{"type": "text", "content": "Compute 15 % 4"}]}]', - GEN_AI_OUTPUT_MESSAGES_KEY: '[{"role": "assistant", "parts": [{"type": "tool_call", "id": "c1"}]}, {"role": "tool", "parts": [{"type": "tool_call_response"}]}, {"role": "assistant", "parts": [{"type": "text", "content": "Result is 3."}]}]', - }, - ) - span.name = "invoke_agent Agent365Assistant" - result = enrich_agent_framework_span(span) - - # Input should be structured array format with user message - input_json = json.loads(result.attributes[GEN_AI_INPUT_MESSAGES_KEY]) - self.assertIsInstance(input_json, list) - self.assertEqual(len(input_json), 1) - self.assertEqual(input_json[0]["role"], "user") - self.assertEqual(input_json[0]["parts"][0]["content"], "Compute 15 % 4") - - # Output should be structured array format: tool_call (no name -> filtered) + tool response + text - output_json = json.loads(result.attributes[GEN_AI_OUTPUT_MESSAGES_KEY]) - self.assertIsInstance(output_json, list) - # tool_call with no name is filtered, tool_call_response with no id/response passes, - # assistant text passes - assistant_msgs = [m for m in output_json if m["role"] == "assistant"] - self.assertTrue(len(assistant_msgs) >= 1) - # At least one assistant message should have a text part - text_parts = [p for m in assistant_msgs for p in m["parts"] if p.get("type") == "text"] - self.assertEqual(text_parts[0]["content"], "Result is 3.") - - def test_execute_tool_span_enrichment(self): - """Test execute_tool span maps tool arguments and result to standard keys.""" - span = Mock( - name="execute_tool calculate", - attributes={ - AF_TOOL_CALL_ARGUMENTS_KEY: '{"expression": "2 + 2"}', - AF_TOOL_CALL_RESULT_KEY: "Result is 4", - }, - ) - span.name = "execute_tool calculate" - result = enrich_agent_framework_span(span) - self.assertEqual(result.attributes[GEN_AI_TOOL_ARGS_KEY], '{"expression": "2 + 2"}') - self.assertEqual(result.attributes[GEN_AI_TOOL_CALL_RESULT_KEY], "Result is 4") - - def test_non_matching_and_edge_cases_return_original(self): - """Test non-matching, None, and empty attribute spans return unchanged.""" - span = Mock(name="other_op", attributes={"key": "value"}) - span.name = "other_op" - self.assertEqual(enrich_agent_framework_span(span), span) - - span.name = "invoke_agent Test" - span.attributes = None - self.assertEqual(enrich_agent_framework_span(span), span) - - span.attributes = {} - self.assertEqual(enrich_agent_framework_span(span), span) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/extensions/agentframework/test_utils.py b/tests/observability/extensions/agentframework/test_utils.py deleted file mode 100644 index dd6d044e..00000000 --- a/tests/observability/extensions/agentframework/test_utils.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for Agent Framework utils.""" - -import unittest - -from microsoft_agents_a365.observability.extensions.agentframework.utils import ( - extract_content_as_string_list, - extract_input_content, - extract_output_content, -) - - -class TestAgentFrameworkUtils(unittest.TestCase): - """Test suite for Agent Framework utility functions.""" - - def test_extract_content_filters_text_by_role(self): - """Test text extraction with role filtering, ignoring tool calls.""" - msgs = '[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}, {"role": "assistant", "parts": [{"type": "tool_call"}, {"type": "text", "content": "Hello"}]}]' - self.assertEqual(extract_content_as_string_list(msgs), '["Hi", "Hello"]') - self.assertEqual(extract_content_as_string_list(msgs, role_filter="user"), '["Hi"]') - self.assertEqual(extract_input_content(msgs), '["Hi"]') - self.assertEqual(extract_output_content(msgs), '["Hello"]') - - def test_handles_invalid_and_edge_cases(self): - """Test invalid JSON and edge cases return appropriate values.""" - self.assertEqual(extract_content_as_string_list("invalid"), "invalid") - self.assertEqual(extract_content_as_string_list('{"not": "list"}'), '{"not": "list"}') - self.assertEqual(extract_content_as_string_list("[]"), "[]") - self.assertEqual(extract_content_as_string_list('[{"role": "user"}]'), "[]") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/extensions/langchain/__init__.py b/tests/observability/extensions/langchain/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/tests/observability/extensions/langchain/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/extensions/langchain/integration/__init__.py b/tests/observability/extensions/langchain/integration/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/tests/observability/extensions/langchain/integration/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/extensions/langchain/integration/conftest.py b/tests/observability/extensions/langchain/integration/conftest.py deleted file mode 100644 index 1df58995..00000000 --- a/tests/observability/extensions/langchain/integration/conftest.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Fixtures for LangChain observability integration tests.""" - -import os -from pathlib import Path -from typing import Any - -import pytest - -try: - from dotenv import load_dotenv - - current_file = Path(__file__) - tests_dir = current_file.parent.parent.parent.parent.parent - env_file = tests_dir / ".env" - if env_file.exists(): - load_dotenv(env_file) -except ImportError: - pass - - -def pytest_configure(config: pytest.Config) -> None: - """Add integration marker.""" - config.addinivalue_line("markers", "integration: marks tests as integration tests") - - -@pytest.fixture(scope="session") -def azure_openai_config() -> dict[str, Any]: - """Azure OpenAI configuration for integration tests.""" - api_key = os.getenv("AZURE_OPENAI_API_KEY") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o-mini") - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview") - - if not api_key or not endpoint: - pytest.skip("Integration tests require AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT") - - return { - "api_key": api_key, - "endpoint": endpoint, - "deployment": deployment, - "api_version": api_version, - } - - -@pytest.fixture(scope="session") -def agent365_config() -> dict[str, Any]: - """Microsoft Agent 365 configuration for integration tests.""" - tenant_id = os.getenv("AGENT365_TEST_TENANT_ID", "4d44f041-f91e-4d00-b107-61e47b26f5a8") - agent_id = os.getenv("AGENT365_TEST_AGENT_ID", "3bccd52b-daaa-4b11-af40-47443852137c") - - if not tenant_id: - pytest.skip("Integration tests require AGENT365_TEST_TENANT_ID") - - return {"tenant_id": tenant_id, "agent_id": agent_id} diff --git a/tests/observability/extensions/langchain/integration/test_message_format.py b/tests/observability/extensions/langchain/integration/test_message_format.py deleted file mode 100644 index ae85762f..00000000 --- a/tests/observability/extensions/langchain/integration/test_message_format.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Integration tests for LangChain message format mapping. - -These tests use the real A365 observability pipeline: - configure() → get_tracer_provider() → CustomLangChainInstrumentor -with a SpanCapturingExporter inside _EnrichingBatchSpanProcessor, then make -real Azure OpenAI calls via LangChain and capture the span attributes. - -These tests verify the serialized gen_ai.input.messages / gen_ai.output.messages -structured array format emitted by the observability pipeline. -""" - -import json -import time -from typing import Any - -import pytest -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult - -try: - from langchain_openai import AzureChatOpenAI -except ImportError: - pytest.skip( - "langchain-openai required for LangChain integration tests", - allow_module_level=True, - ) - -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from microsoft_agents_a365.observability.extensions.langchain import ( - CustomLangChainInstrumentor, -) - - -class SpanCapturingExporter(SpanExporter): - """Exporter that collects spans in-memory after enrichment.""" - - def __init__(self) -> None: - self.spans: list[ReadableSpan] = [] - - def export(self, spans: list[ReadableSpan]) -> SpanExportResult: - self.spans.extend(spans) - return SpanExportResult.SUCCESS - - def shutdown(self) -> None: - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - return True - - -@pytest.mark.integration -class TestLangChainMessageFormat: - """Capture real LangChain span attributes and verify message structure.""" - - @pytest.fixture(autouse=True) - def setup_observability(self) -> None: - """Set up A365 observability with CustomLangChainInstrumentor.""" - if not hasattr(TestLangChainMessageFormat, "_exporter"): - configure( - service_name="integration-test-langchain", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - exporter = SpanCapturingExporter() - provider = get_tracer_provider() - provider.add_span_processor( - _EnrichingBatchSpanProcessor( - exporter, - max_queue_size=100, - schedule_delay_millis=100, - max_export_batch_size=100, - ) - ) - - # CustomLangChainInstrumentor calls instrument() in __init__ - instrumentor = CustomLangChainInstrumentor() - - TestLangChainMessageFormat._exporter = exporter - TestLangChainMessageFormat._instrumentor = instrumentor - - self.exporter = TestLangChainMessageFormat._exporter - self.exporter.spans.clear() - - @pytest.fixture - def llm(self, azure_openai_config: dict[str, Any]) -> AzureChatOpenAI: - """Create a real Azure OpenAI LangChain chat model.""" - return AzureChatOpenAI( - azure_endpoint=azure_openai_config["endpoint"], - api_key=azure_openai_config["api_key"], - azure_deployment=azure_openai_config["deployment"], - api_version=azure_openai_config["api_version"], - ) - - def _find_chat_spans(self) -> list[ReadableSpan]: - """Find exported spans that have gen_ai.input.messages.""" - get_tracer_provider().force_flush() - time.sleep(0.5) - return [ - s - for s in self.exporter.spans - if s.attributes and GEN_AI_INPUT_MESSAGES_KEY in s.attributes - ] - - @pytest.mark.asyncio - async def test_simple_chat_message_mapping(self, llm: AzureChatOpenAI) -> None: - """Simple chat: capture LangChain message format on exported spans.""" - from langchain_core.messages import HumanMessage, SystemMessage - - messages = [ - SystemMessage(content="You are a helpful assistant. Reply in one sentence."), - HumanMessage(content="What is the capital of France?"), - ] - - result = await llm.ainvoke(messages) - assert result is not None - assert len(result.content) > 0 - - chat_spans = self._find_chat_spans() - assert len(chat_spans) > 0, ( - f"No chat spans found. All spans: {[s.name for s in self.exporter.spans]}" - ) - - print(f"\n=== All exported spans ({len(self.exporter.spans)}) ===") - for s in self.exporter.spans: - attrs = dict(s.attributes or {}) - print(f" {s.name} | attrs: {list(attrs.keys())}") - - attrs = dict(chat_spans[-1].attributes or {}) - - # --- Input messages --- - raw_input = attrs[GEN_AI_INPUT_MESSAGES_KEY] - print(f"\n=== gen_ai.input.messages ===\n{raw_input}") - input_data = json.loads(raw_input) - - # Verify structured array format - assert isinstance(input_data, list) - messages_list = input_data - for msg in messages_list: - assert "role" in msg - assert "parts" in msg - print("\n ✓ Structured array format detected") - - # --- Output messages --- - raw_output = attrs.get(GEN_AI_OUTPUT_MESSAGES_KEY) - assert raw_output is not None, "gen_ai.output.messages not found" - print(f"\n=== gen_ai.output.messages ===\n{raw_output}") - output_data = json.loads(raw_output) - - assert isinstance(output_data, list) - for msg in output_data: - assert msg["role"] == "assistant" - assert any(p["type"] == "text" for p in msg["parts"]) - print("\n ✓ Structured array format detected") - - @pytest.mark.asyncio - async def test_tool_call_message_mapping(self, llm: AzureChatOpenAI) -> None: - """Tool-calling chat: capture tool_call parts in LangChain spans.""" - from langchain_core.messages import HumanMessage, SystemMessage - from langchain_core.tools import tool - - @tool - def get_weather(city: str) -> str: - """Get the current weather for a city.""" - return f"The weather in {city} is sunny, 22°C." - - llm_with_tools = llm.bind_tools([get_weather]) - - messages = [ - SystemMessage(content="You are a weather assistant. Always use the get_weather tool."), - HumanMessage(content="What's the weather in Seattle?"), - ] - - result = await llm_with_tools.ainvoke(messages) - assert result is not None - - chat_spans = self._find_chat_spans() - assert len(chat_spans) > 0 - - print(f"\n=== All exported spans ({len(self.exporter.spans)}) ===") - for s in self.exporter.spans: - attrs = dict(s.attributes or {}) - op = attrs.get(GEN_AI_OPERATION_NAME_KEY, "(none)") - print(f" {s.name} | op={op} | attrs: {list(attrs.keys())}") - - # Check all spans for message content - for span in chat_spans: - attrs = dict(span.attributes or {}) - for key in (GEN_AI_INPUT_MESSAGES_KEY, GEN_AI_OUTPUT_MESSAGES_KEY): - raw = attrs.get(key) - if raw: - print(f"\n--- {span.name} | {key} ---\n{raw}") diff --git a/tests/observability/extensions/langchain/integration/test_observability_pipeline.py b/tests/observability/extensions/langchain/integration/test_observability_pipeline.py deleted file mode 100644 index fd4551c1..00000000 --- a/tests/observability/extensions/langchain/integration/test_observability_pipeline.py +++ /dev/null @@ -1,422 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""End-to-end pipeline integration tests for LangChain observability. - -These tests verify the full A365 observability pipeline: - InvokeAgentScope → Inference (auto-instrumented) → ToolExecution (auto-instrumented) - -The CustomLangChainInstrumentor automatically creates inference spans for LLM -calls and execute_tool spans for tool runs. Its built-in message mapper -converts LangChain messages into the structured array A365 message format -(``[...]``) on ``gen_ai.input.messages`` and ``gen_ai.output.messages`` -span attributes. - -Wrapping the entire call in InvokeAgentScope makes all auto-instrumented spans -children of the invoke_agent span (since ``separate_trace_from_runtime_context`` -defaults to ``False``). -""" - -import json -import os -import time - -import pytest -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult - -try: - from langchain_core.messages import HumanMessage, SystemMessage - from langchain_core.tools import tool - from langchain_openai import AzureChatOpenAI -except ImportError: - pytest.skip( - "langchain-openai required for LangChain integration tests", - allow_module_level=True, - ) - -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.agent_details import AgentDetails -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - INVOKE_AGENT_OPERATION_NAME, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentScopeDetails -from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope -from microsoft_agents_a365.observability.core.request import Request -from microsoft_agents_a365.observability.extensions.langchain import ( - CustomLangChainInstrumentor, -) - -# Ensure A365 observability scopes are active for tests -os.environ["ENABLE_A365_OBSERVABILITY"] = "true" - - -@tool -def add_numbers(a: float, b: float) -> str: - """Add two numbers together. - - Args: - a: First number - b: Second number - - Returns: - A string describing the sum. - """ - return f"The sum of {a} and {b} is {a + b}" - - -class SpanCapturingExporter(SpanExporter): - """Exporter that collects spans in-memory after enrichment.""" - - def __init__(self) -> None: - self.spans: list[ReadableSpan] = [] - - def export(self, spans: list[ReadableSpan]) -> SpanExportResult: - self.spans.extend(spans) - return SpanExportResult.SUCCESS - - def shutdown(self) -> None: - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - return True - - -def _get_span_attr(span: ReadableSpan, key: str) -> str | None: - """Safely get an attribute from a span.""" - attrs = span.attributes or {} - return attrs.get(key) - - -def _find_spans_by_name_prefix(spans: list[ReadableSpan], prefix: str) -> list[ReadableSpan]: - """Find spans whose name starts with a given prefix.""" - return [s for s in spans if s.name.startswith(prefix)] - - -@pytest.mark.integration -class TestLangChainObservabilityPipeline: - """End-to-end pipeline tests: InvokeAgent → Inference → ToolExecution. - - Verifies that wrapping LangChain calls inside InvokeAgentScope - produces a single trace with correct parent-child span hierarchy, - operation names, and A365 structured array message format attributes. - """ - - @pytest.fixture(autouse=True) - def setup_observability(self) -> None: - """Set up A365 observability with CustomLangChainInstrumentor.""" - if not hasattr(TestLangChainObservabilityPipeline, "_exporter"): - configure( - service_name="integration-test-langchain-pipeline", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - exporter = SpanCapturingExporter() - provider = get_tracer_provider() - provider.add_span_processor( - _EnrichingBatchSpanProcessor( - exporter, - max_queue_size=100, - schedule_delay_millis=100, - max_export_batch_size=100, - ) - ) - - # CustomLangChainInstrumentor instruments on init - instrumentor = CustomLangChainInstrumentor() - - TestLangChainObservabilityPipeline._exporter = exporter - TestLangChainObservabilityPipeline._instrumentor = instrumentor - - self.exporter = TestLangChainObservabilityPipeline._exporter - self.exporter.spans.clear() - - @pytest.fixture - def llm(self, azure_openai_config: dict) -> AzureChatOpenAI: - """Create a real Azure OpenAI LangChain chat model.""" - return AzureChatOpenAI( - azure_endpoint=azure_openai_config["endpoint"], - api_key=azure_openai_config["api_key"], - azure_deployment=azure_openai_config["deployment"], - api_version=azure_openai_config["api_version"], - ) - - @pytest.fixture - def agent_details(self) -> AgentDetails: - """Create AgentDetails for the test agent.""" - tenant_id = os.getenv("AGENT365_TEST_TENANT_ID", "4d44f041-f91e-4d00-b107-61e47b26f5a8") - agent_id = os.getenv("AGENT365_TEST_AGENT_ID", "3bccd52b-daaa-4b11-af40-47443852137c") - return AgentDetails( - agent_id=agent_id, - agent_name="langchain-pipeline-test-agent", - agent_description="Integration test agent for LangChain pipeline verification", - tenant_id=tenant_id, - ) - - def _flush_and_collect(self) -> list[ReadableSpan]: - """Force flush and return all captured spans.""" - get_tracer_provider().force_flush() - time.sleep(0.5) - return list(self.exporter.spans) - - # ------------------------------------------------------------------ - # Test: Full pipeline with tool execution loop - # ------------------------------------------------------------------ - - @pytest.mark.asyncio - async def test_pipeline_invoke_agent_with_tool_call( - self, - llm: AzureChatOpenAI, - agent_details: AgentDetails, - ) -> None: - """Full pipeline: InvokeAgentScope wraps LangChain with tool execution. - - Uses a manual tool loop: LLM call → tool execution → LLM call. - This ensures both inference and tool execution spans are created. - - Verifies: - 1. All spans share the same trace_id - 2. invoke_agent span is the root (no parent) - 3. Inference spans are descendants of invoke_agent - 4. Tool execution spans are descendants of invoke_agent - 5. A365 message format on inference spans - """ - from langchain_core.messages import ToolMessage - - request = Request(content="What is 15 + 27?", session_id="test-langchain-pipeline") - - llm_with_tools = llm.bind_tools([add_numbers]) - - with InvokeAgentScope.start( - request=request, - scope_details=InvokeAgentScopeDetails(), - agent_details=agent_details, - ): - messages = [ - SystemMessage( - content=( - "You are a math assistant. You MUST use the add_numbers tool " - "for any arithmetic. Never compute in your head." - ) - ), - HumanMessage(content="What is 15 + 27?"), - ] - - # First LLM call — should produce a tool_calls response - ai_response = await llm_with_tools.ainvoke(messages) - messages.append(ai_response) - - # Execute tool calls if present - if hasattr(ai_response, "tool_calls") and ai_response.tool_calls: - for tc in ai_response.tool_calls: - tool_result = add_numbers.invoke(tc) - messages.append(ToolMessage(content=str(tool_result), tool_call_id=tc["id"])) - - # Second LLM call with tool results - final_response = await llm_with_tools.ainvoke(messages) - else: - final_response = ai_response - - assert final_response is not None - assert len(str(final_response.content)) > 0 - - spans = self._flush_and_collect() - assert len(spans) > 0, "No spans were captured" - - # --- Print span tree for debugging --- - print(f"\n=== Captured {len(spans)} spans ===") - for s in spans: - op = _get_span_attr(s, GEN_AI_OPERATION_NAME_KEY) or "(none)" - parent_id = f"{s.parent.span_id:016x}" if s.parent else "None" - print( - f" {s.name} | op={op} | trace={s.context.trace_id:032x} " - f"| span={s.context.span_id:016x} | parent={parent_id}" - ) - - # --- 1. Find invoke_agent span --- - invoke_spans = _find_spans_by_name_prefix(spans, "invoke_agent") - assert len(invoke_spans) >= 1, ( - f"Expected at least 1 invoke_agent span, got: {[s.name for s in spans]}" - ) - invoke_span = invoke_spans[0] - trace_id = invoke_span.context.trace_id - - # --- 2. All spans share the same trace_id --- - for s in spans: - assert s.context.trace_id == trace_id, ( - f"Span '{s.name}' has different trace_id: " - f"{s.context.trace_id:032x} vs {trace_id:032x}" - ) - - # --- 3. invoke_agent span is the root --- - assert invoke_span.parent is None, ( - f"invoke_agent should be root but has parent: {invoke_span.parent.span_id:016x}" - ) - - # --- 4. invoke_agent has correct operation name --- - assert _get_span_attr(invoke_span, GEN_AI_OPERATION_NAME_KEY) == INVOKE_AGENT_OPERATION_NAME - - # --- 5. Tenant ID is set --- - assert _get_span_attr(invoke_span, TENANT_ID_KEY) == agent_details.tenant_id - - # --- 6. Inference spans are descendants of invoke_agent --- - # LangChain inference spans typically have chat operation or input messages - inference_spans = [ - s - for s in spans - if s != invoke_span - and ( - _get_span_attr(s, GEN_AI_OPERATION_NAME_KEY) == "chat" - or _get_span_attr(s, GEN_AI_INPUT_MESSAGES_KEY) is not None - ) - and not s.name.startswith("execute_tool") - ] - assert len(inference_spans) >= 1, ( - f"Expected at least 1 inference span, got: {[s.name for s in spans]}" - ) - - invoke_span_id = invoke_span.context.span_id - for inf_span in inference_spans: - self._assert_ancestor( - inf_span, - invoke_span_id, - spans, - f"Inference span '{inf_span.name}' is not a descendant of invoke_agent", - ) - - # --- 7. Tool execution spans are descendants of invoke_agent --- - tool_spans = _find_spans_by_name_prefix(spans, "execute_tool") - # Tool execution spans may or may not appear depending on whether - # the LangChain tracer emits them. If present, verify hierarchy. - if tool_spans: - for tool_span in tool_spans: - self._assert_ancestor( - tool_span, - invoke_span_id, - spans, - f"Tool span '{tool_span.name}' is not a descendant of invoke_agent", - ) - print(f"\n✓ Found {len(tool_spans)} tool execution spans") - - # --- 8. A365 message format on inference spans --- - # The A365 mapper emits the structured array format [...]. - for inf_span in inference_spans: - attrs = dict(inf_span.attributes or {}) - if GEN_AI_INPUT_MESSAGES_KEY in attrs: - input_data = json.loads(attrs[GEN_AI_INPUT_MESSAGES_KEY]) - if isinstance(input_data, list): - for msg in input_data: - assert "role" in msg - assert "parts" in msg - - if GEN_AI_OUTPUT_MESSAGES_KEY in attrs: - output_data = json.loads(attrs[GEN_AI_OUTPUT_MESSAGES_KEY]) - if isinstance(output_data, list): - for msg in output_data: - assert "role" in msg - assert "parts" in msg - - print( - f"\n✓ All pipeline assertions passed: " - f"{len(spans)} spans, {len(inference_spans)} inference, " - f"{len(tool_spans)} tool" - ) - - # ------------------------------------------------------------------ - # Test: Pipeline without tools (simple inference only) - # ------------------------------------------------------------------ - - @pytest.mark.asyncio - async def test_pipeline_invoke_agent_simple_inference( - self, - llm: AzureChatOpenAI, - agent_details: AgentDetails, - ) -> None: - """Pipeline with InvokeAgentScope + simple inference (no tools). - - Verifies invoke_agent → inference span hierarchy. - """ - request = Request(content="Say hello", session_id="test-langchain-simple") - - with InvokeAgentScope.start( - request=request, - scope_details=InvokeAgentScopeDetails(), - agent_details=agent_details, - ): - messages = [ - SystemMessage(content="You are a helpful assistant. Reply in one sentence."), - HumanMessage(content="Say hello in exactly 5 words."), - ] - result = await llm.ainvoke(messages) - - assert result is not None - assert len(str(result.content)) > 0 - - spans = self._flush_and_collect() - assert len(spans) > 0, "No spans were captured" - - # All spans share the same trace_id - invoke_spans = _find_spans_by_name_prefix(spans, "invoke_agent") - assert len(invoke_spans) >= 1 - invoke_span = invoke_spans[0] - trace_id = invoke_span.context.trace_id - - for s in spans: - assert s.context.trace_id == trace_id - - # invoke_agent is root - assert invoke_span.parent is None - - # Inference spans are descendants - inference_spans = [ - s - for s in spans - if s != invoke_span and _get_span_attr(s, GEN_AI_INPUT_MESSAGES_KEY) is not None - ] - assert len(inference_spans) >= 1 - - invoke_span_id = invoke_span.context.span_id - for inf_span in inference_spans: - self._assert_ancestor( - inf_span, - invoke_span_id, - spans, - f"Inference span '{inf_span.name}' not a descendant of invoke_agent", - ) - - print(f"\n✓ Simple pipeline: {len(spans)} spans, hierarchy verified") - - # ------------------------------------------------------------------ - # Helper: assert ancestor relationship - # ------------------------------------------------------------------ - - def _assert_ancestor( - self, - span: ReadableSpan, - ancestor_span_id: int, - all_spans: list[ReadableSpan], - message: str, - ) -> None: - """Walk up the parent chain and assert that ancestor_span_id is found.""" - span_map = {s.context.span_id: s for s in all_spans} - current = span - visited: set[int] = set() - while current.parent is not None: - parent_id = current.parent.span_id - if parent_id == ancestor_span_id: - return - if parent_id in visited: - break - visited.add(parent_id) - current = span_map.get(parent_id) - if current is None: - break - raise AssertionError(message) diff --git a/tests/observability/extensions/langchain/test_tracer_bounded.py b/tests/observability/extensions/langchain/test_tracer_bounded.py deleted file mode 100644 index b70c920b..00000000 --- a/tests/observability/extensions/langchain/test_tracer_bounded.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for bounded collections in the LangChain tracer.""" - -import unittest -from collections import OrderedDict -from unittest.mock import MagicMock, patch -from uuid import uuid4 - -from microsoft_agents_a365.observability.extensions.langchain.tracer import ( - CustomLangChainTracer, -) - - -class TestLangChainTracerBounded(unittest.TestCase): - """Tests that LangChain tracer collections are properly bounded.""" - - def _make_tracer(self) -> CustomLangChainTracer: - """Create a tracer with a mock OTel tracer.""" - mock_otel_tracer = MagicMock() - mock_span = MagicMock() - mock_otel_tracer.start_span.return_value = mock_span - return CustomLangChainTracer( - tracer=mock_otel_tracer, - separate_trace_from_runtime_context=True, - ) - - def test_spans_by_run_is_ordered_dict(self): - """_spans_by_run should be an OrderedDict for bounded eviction.""" - tracer = self._make_tracer() - self.assertIsInstance(tracer._spans_by_run, OrderedDict) - - def test_cap_ordered_dict_evicts_oldest(self): - """_cap_ordered_dict should evict oldest entries (FIFO).""" - d: OrderedDict[str, int] = OrderedDict() - for i in range(15): - d[f"key_{i}"] = i - CustomLangChainTracer._cap_ordered_dict(d, 10) - - self.assertEqual(len(d), 10) - # oldest 5 should be gone - for i in range(5): - self.assertNotIn(f"key_{i}", d) - # newest 10 should remain - for i in range(5, 15): - self.assertIn(f"key_{i}", d) - self.assertEqual(d[f"key_{i}"], i) - - def test_cap_ordered_dict_noop_when_under_limit(self): - """_cap_ordered_dict should be a no-op when size is under limit.""" - d: OrderedDict[str, int] = OrderedDict() - for i in range(5): - d[f"key_{i}"] = i - CustomLangChainTracer._cap_ordered_dict(d, 10) - self.assertEqual(len(d), 5) - - def test_spans_by_run_bounded_on_start_trace(self): - """_spans_by_run should be bounded when _start_trace adds entries.""" - tracer = self._make_tracer() - # Use a small cap for testing - original_max = CustomLangChainTracer._MAX_TRACKED_RUNS - try: - CustomLangChainTracer._MAX_TRACKED_RUNS = 5 - - # Add more runs than the cap - for i in range(10): - run = MagicMock() - run.id = uuid4() - run.parent_run_id = None - run.run_type = "llm" - run.name = f"test_run_{i}" - run.start_time = MagicMock() - - with patch( - "microsoft_agents_a365.observability.extensions.langchain.tracer" - ".context_api.get_value", - return_value=None, - ): - tracer._start_trace(run) - - # Should be capped at 5 - self.assertLessEqual(len(tracer._spans_by_run), 5) - finally: - CustomLangChainTracer._MAX_TRACKED_RUNS = original_max - - def test_get_span_returns_none_for_missing(self): - """get_span should return None for non-existent run_id.""" - tracer = self._make_tracer() - result = tracer.get_span(uuid4()) - self.assertIsNone(result) - - def test_get_span_returns_span_for_existing(self): - """get_span should return the span for existing run_id.""" - tracer = self._make_tracer() - run_id = uuid4() - mock_span = MagicMock() - with tracer._lock: - tracer._spans_by_run[run_id] = mock_span - result = tracer.get_span(run_id) - self.assertEqual(result, mock_span) - - def test_max_tracked_runs_default(self): - """Default _MAX_TRACKED_RUNS should be 10000.""" - self.assertEqual(CustomLangChainTracer._MAX_TRACKED_RUNS, 10000) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/extensions/langchain/test_wrapper_langchain.py b/tests/observability/extensions/langchain/test_wrapper_langchain.py deleted file mode 100644 index ce93b7da..00000000 --- a/tests/observability/extensions/langchain/test_wrapper_langchain.py +++ /dev/null @@ -1,370 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from unittest.mock import MagicMock -from uuid import uuid4 - -from microsoft_agents_a365.observability.core import configure -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_REQUEST_MODEL_KEY, - GEN_AI_RESPONSE_FINISH_REASONS_KEY, - GEN_AI_RESPONSE_MODEL_KEY, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_INPUT_TOKENS_KEY, - GEN_AI_USAGE_OUTPUT_TOKENS_KEY, -) -from microsoft_agents_a365.observability.extensions.langchain.tracer_instrumentor import ( - CustomLangChainInstrumentor, -) -from microsoft_agents_a365.observability.extensions.langchain.utils import model_name, token_counts - - -class TestLangChainModelObservationAttributes(unittest.TestCase): - """Unit tests for model observation attribute mapping.""" - - def test_request_and_response_models_are_distinct(self): - """Test that requested and served model names use distinct attributes.""" - outputs = {"llm_output": {"model_name": "gpt-4.1-2025-04-14"}} - extra = {"metadata": {"ls_model_name": "gpt-4.1"}} - - attributes = dict(model_name(outputs, extra)) - - self.assertEqual(attributes[GEN_AI_REQUEST_MODEL_KEY], "gpt-4.1") - self.assertEqual(attributes[GEN_AI_RESPONSE_MODEL_KEY], "gpt-4.1-2025-04-14") - - def test_cache_token_counts_use_standard_attributes(self): - """Test that cache token counts use model observation attributes.""" - outputs = { - "llm_output": { - "token_usage": { - "input_tokens": 100, - "output_tokens": 20, - "input_token_details": { - "cache_read": 40, - "cache_creation": 10, - }, - } - } - } - - attributes = dict(token_counts(outputs)) - - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY], 40) - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY], 10) - - def test_cache_token_counts_read_from_generated_message_usage_metadata(self): - """ - Test that cache token counts are extracted from the generated - message's normalized `usage_metadata` (LangChain's `dumpd`-style - serialized AIMessage), not just from a synthetic `llm_output` - location. This mirrors the actual shape LangChain produces for - providers such as ChatAnthropic, where the raw `llm_output["usage"]` - only carries flat (non-normalized) fields while the message itself - carries the normalized `input_token_details`. - """ - outputs = { - "llm_output": { - # Anthropic's raw usage payload: flat cache fields, no - # normalized input_token_details. - "usage": { - "input_tokens": 100, - "output_tokens": 20, - "cache_read_input_tokens": 40, - "cache_creation_input_tokens": 10, - } - }, - "generations": [ - [ - { - "message": { - "lc": 1, - "type": "constructor", - "id": ["langchain", "schema", "messages", "AIMessage"], - "kwargs": { - "content": "hello", - "usage_metadata": { - "input_tokens": 100, - "output_tokens": 20, - "total_tokens": 120, - "input_token_details": { - "cache_read": 40, - "cache_creation": 10, - }, - }, - }, - } - } - ] - ], - } - - attributes = dict(token_counts(outputs)) - - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY], 40) - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY], 10) - - def test_cache_token_counts_fall_back_to_raw_anthropic_flat_fields(self): - """ - Test that cache token counts fall back to Anthropic's raw, flat - `llm_output["usage"]` fields when no normalized `usage_metadata` is - available on the generated message. - """ - outputs = { - "llm_output": { - "usage": { - "input_tokens": 100, - "output_tokens": 20, - "cache_read_input_tokens": 40, - "cache_creation_input_tokens": 10, - } - }, - "generations": [ - [{"message": {"lc": 1, "type": "constructor", "kwargs": {"content": "hello"}}}] - ], - } - - attributes = dict(token_counts(outputs)) - - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY], 40) - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY], 10) - - def test_reasoning_tokens_from_message_usage_metadata_are_not_emitted_as_finish_reasons(self): - """ - Test that a reasoning *token count* sourced only from the generated - message's normalized `usage_metadata` (no `llm_output` present) is - never emitted under `GEN_AI_RESPONSE_FINISH_REASONS_KEY` - that - attribute is reserved for actual finish reasons, not token counts. - """ - outputs = { - "generations": [ - [ - { - "message": { - "lc": 1, - "type": "constructor", - "id": ["langchain", "schema", "messages", "AIMessage"], - "kwargs": { - "content": "hello", - "usage_metadata": { - "input_tokens": 100, - "output_tokens": 20, - "total_tokens": 120, - "output_token_details": { - "reasoning": 8, - }, - }, - }, - } - } - ] - ], - } - - attributes = dict(token_counts(outputs)) - - self.assertNotIn(GEN_AI_RESPONSE_FINISH_REASONS_KEY, attributes) - self.assertEqual(attributes[GEN_AI_USAGE_INPUT_TOKENS_KEY], 100) - self.assertEqual(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_KEY], 20) - - def test_token_counts_are_aggregated_across_batch_entries(self): - """ - Test that `token_counts` sums usage across every batch entry in - `outputs["generations"]` (one entry per input in a batched call), - rather than only reading the first batch entry and silently - dropping usage for the rest. - """ - outputs = { - "generations": [ - [ - { - "message": { - "lc": 1, - "type": "constructor", - "id": ["langchain", "schema", "messages", "AIMessage"], - "kwargs": { - "content": "first", - "usage_metadata": { - "input_tokens": 10, - "output_tokens": 2, - "total_tokens": 12, - "input_token_details": {"cache_read": 1}, - }, - }, - } - } - ], - [ - { - "message": { - "lc": 1, - "type": "constructor", - "id": ["langchain", "schema", "messages", "AIMessage"], - "kwargs": { - "content": "second", - "usage_metadata": { - "input_tokens": 100, - "output_tokens": 20, - "total_tokens": 120, - "input_token_details": {"cache_read": 4}, - }, - }, - } - } - ], - ], - } - - attributes = dict(token_counts(outputs)) - - self.assertEqual(attributes[GEN_AI_USAGE_INPUT_TOKENS_KEY], 110) - self.assertEqual(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_KEY], 22) - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY], 5) - - -class TestInstrumentorLangChain(unittest.TestCase): - """Unit tests for InstrumentorForLangChain class.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - configure( - service_name="test-service-langchain", - service_namespace="test-namespace-langchain", - ) - - def setUp(self): - """Reset instrumentation state before each test.""" - # Clear the global instrumentation state - CustomLangChainInstrumentor._instance = None - if hasattr(CustomLangChainInstrumentor, "_instrumented"): - CustomLangChainInstrumentor._instrumented = False - - def tearDown(self): - """Clean up after each test.""" - try: - instrumentor = getattr(self, "_test_instrumentor", None) - if instrumentor: - instrumentor._uninstrument() - except Exception: - pass - - def test_instrumentor_initialization_and_Agent365_integration(self): - """Test 1: Verify InstrumentorForLangChain initializes and integrates with Microsoft Agent 365.""" - from microsoft_agents_a365.observability.core.config import get_tracer, is_configured - - # Verify Microsoft Agent 365 is configured - self.assertTrue(is_configured(), "Microsoft Agent 365 should be configured") - - # Get tracer to ensure it works - tracer = get_tracer() - self.assertIsNotNone(tracer, "Microsoft Agent 365 tracer should be available") - - # Create instrumentor - self._test_instrumentor = CustomLangChainInstrumentor() - - # Verify the object was created - self.assertIsNotNone(self._test_instrumentor) - self.assertIsInstance(self._test_instrumentor, CustomLangChainInstrumentor) - - # Check if it has expected attributes/methods - self.assertTrue(hasattr(self._test_instrumentor, "_instrument")) - self.assertTrue(hasattr(self._test_instrumentor, "_uninstrument")) - self.assertTrue(hasattr(self._test_instrumentor, "get_span")) - self.assertTrue(hasattr(self._test_instrumentor, "get_ancestors")) - - # Check that tracer was created during initialization - self.assertIsNotNone( - self._test_instrumentor._tracer, "Tracer should be created after initialization" - ) - - print( - "✅ Test 1: InstrumentorForLangChain initialization and Microsoft Agent 365 integration works" - ) - - def test_instrumentor_get_span_functionality(self): - """Test 2: Verify get_span method works correctly with mock data.""" - self._test_instrumentor = CustomLangChainInstrumentor() - - # Test with non-existent run_id - test_run_id = uuid4() - span = self._test_instrumentor.get_span(test_run_id) - self.assertIsNone(span, "get_span should return None for non-existent run_id") - - # Test with mock span data - if self._test_instrumentor._tracer is not None: - # Mock the internal _spans_by_run dict - mock_span = MagicMock() - self._test_instrumentor._tracer._spans_by_run[test_run_id] = mock_span - - result_span = self._test_instrumentor.get_span(test_run_id) - self.assertEqual(result_span, mock_span, "get_span should return the correct span") - - # Clean up the mock data - del self._test_instrumentor._tracer._spans_by_run[test_run_id] - - print("✅ Test 2: get_span functionality works correctly") - - def test_instrumentor_wrapping_mechanism(self): - """Test 3: Verify instrumentor properly wraps LangChain BaseCallbackManager.""" - self._test_instrumentor = CustomLangChainInstrumentor() - - # Verify instrumentor has internal tracer - self.assertIsNotNone( - self._test_instrumentor._tracer, "Tracer should be created during instrumentation" - ) - - # Verify original callback init was saved - self.assertIsNotNone( - self._test_instrumentor._original_cb_init, "Original callback init should be saved" - ) - - # Verify get_span and get_ancestors methods work - self.assertTrue(callable(self._test_instrumentor.get_span)) - self.assertTrue(callable(self._test_instrumentor.get_ancestors)) - - # Test get_ancestors with empty result - test_run_id = uuid4() - ancestors = self._test_instrumentor.get_ancestors(test_run_id) - self.assertEqual( - ancestors, [], "get_ancestors should return empty list for non-existent run" - ) - - print("✅ Test 3: LangChain BaseCallbackManager wrapping works correctly") - - -def run_langchain_tests(): - """Run all LangChain wrapper tests with detailed output.""" - print("🧪 Running Microsoft Agent 365 LangChain Instrumentor tests...") - print("=" * 80) - - # Create test suite - loader = unittest.TestLoader() - suite = unittest.TestSuite() - - # Add test class - suite.addTests(loader.loadTestsFromTestCase(TestInstrumentorLangChain)) - - # Run tests - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - - # Summary - print("\n" + "=" * 80) - print("🏁 LangChain Test Summary:") - print(f"Tests run: {result.testsRun}") - print(f"Failures: {len(result.failures)}") - print(f"Errors: {len(result.errors)}") - - if result.wasSuccessful(): - print("🎉 All LangChain tests passed!") - return True - else: - print("🔧 Some LangChain tests failed. Check output above.") - return False - - -if __name__ == "__main__": - success = run_langchain_tests() - exit(0 if success else 1) diff --git a/tests/observability/extensions/openai/__init__.py b/tests/observability/extensions/openai/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/tests/observability/extensions/openai/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/extensions/openai/integration/__init__.py b/tests/observability/extensions/openai/integration/__init__.py deleted file mode 100644 index 30fd8f65..00000000 --- a/tests/observability/extensions/openai/integration/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Integration tests for Microsoft Agent 365 Python SDK.""" diff --git a/tests/observability/extensions/openai/integration/conftest.py b/tests/observability/extensions/openai/integration/conftest.py deleted file mode 100644 index 2d99cf44..00000000 --- a/tests/observability/extensions/openai/integration/conftest.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -from pathlib import Path -from typing import Any - -import pytest - -# Load .env file if it exists (for local development) -try: - from dotenv import load_dotenv - - # Look for .env file in tests directory - # Navigate from conftest.py location: integration -> openai -> extensions -> observability -> tests - current_file = Path(__file__) - tests_dir = current_file.parent.parent.parent.parent.parent # Go up to tests/ directory - env_file = tests_dir / ".env" - if env_file.exists(): - load_dotenv(env_file) -except ImportError: - # python-dotenv not installed, skip loading .env file - pass - - -def pytest_configure(config): - """Add integration marker.""" - config.addinivalue_line("markers", "integration: marks tests as integration tests") - - -@pytest.fixture(scope="session") -def azure_openai_config() -> dict[str, Any]: - """Azure OpenAI configuration for integration tests.""" - api_key = os.getenv("AZURE_OPENAI_API_KEY") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4") - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview") - - if not api_key or not endpoint: - pytest.skip("Integration tests require AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT") - - return { - "api_key": api_key, - "endpoint": endpoint, - "deployment": deployment, - "api_version": api_version, - } - - -@pytest.fixture(scope="session") -def agent365_config() -> dict[str, Any]: - """Microsoft Agent 365 configuration for integration tests.""" - tenant_id = os.getenv("AGENT365_TEST_TENANT_ID", "4d44f041-f91e-4d00-b107-61e47b26f5a8") - agent_id = os.getenv("AGENT365_TEST_AGENT_ID", "3bccd52b-daaa-4b11-af40-47443852137c") - - if not tenant_id: - pytest.skip("Integration tests require AGENT365_TEST_TENANT_ID") - - return {"tenant_id": tenant_id, "agent_id": agent_id} diff --git a/tests/observability/extensions/openai/integration/test_message_format.py b/tests/observability/extensions/openai/integration/test_message_format.py deleted file mode 100644 index 8c573e88..00000000 --- a/tests/observability/extensions/openai/integration/test_message_format.py +++ /dev/null @@ -1,284 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Integration tests for OpenAI message format mapping. - -These tests use the real A365 observability pipeline: - configure() → get_tracer_provider() → OpenAIAgentsTraceInstrumentor -with real Azure OpenAI API calls. The message mapping is applied directly -in trace_processor before spans are ended, converting raw OpenAI messages -to the A365 structured array format with typed parts. -""" - -import json -import time -from typing import Any - -import pytest -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult - -try: - from agents import Agent, OpenAIChatCompletionsModel, Runner, function_tool - from openai import AsyncAzureOpenAI -except ImportError: - pytest.skip( - "OpenAI agents library and dependencies required for integration tests", - allow_module_level=True, - ) - -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, -) -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) -from microsoft_agents_a365.observability.extensions.openai import ( - OpenAIAgentsTraceInstrumentor, -) - - -@function_tool -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The city name to get weather for. - - Returns: - A string describing the weather. - """ - return f"The weather in {city} is sunny, 22°C." - - -class SpanCapturingExporter(SpanExporter): - """Exporter that collects enriched spans in-memory.""" - - def __init__(self) -> None: - self.spans: list[ReadableSpan] = [] - - def export(self, spans: list[ReadableSpan]) -> SpanExportResult: - self.spans.extend(spans) - return SpanExportResult.SUCCESS - - def shutdown(self) -> None: - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - return True - - -def _span_to_json(span: ReadableSpan) -> dict[str, object]: - """Convert a ReadableSpan (or EnrichedReadableSpan) to a JSON-serializable dict.""" - try: - ctx = span.get_span_context() - context_dict: dict[str, object] = { - "trace_id": f"0x{ctx.trace_id:032x}", - "span_id": f"0x{ctx.span_id:016x}", - } - except (AttributeError, TypeError): - context_dict = {"note": "context not available on enriched span"} - - try: - parent = span.parent - parent_id = f"0x{parent.span_id:016x}" if parent else None - except (AttributeError, TypeError): - parent_id = None - - events_list: list[dict[str, object]] = [] - for e in getattr(span, "events", None) or []: - events_list.append({ - "name": e.name, - "attributes": dict(e.attributes) if e.attributes else {}, - }) - - links_list: list[dict[str, object]] = [] - for lnk in getattr(span, "links", None) or []: - links_list.append({ - "attributes": dict(lnk.attributes) if lnk.attributes else {}, - }) - - result: dict[str, object] = { - "name": span.name, - "context": context_dict, - "kind": str(getattr(span, "kind", None)), - "parent_id": parent_id, - "status": str(getattr(span, "status", None)), - "attributes": dict(span.attributes) if span.attributes else {}, - "events": events_list, - "links": links_list, - } - - resource = getattr(span, "resource", None) - if resource: - result["resource"] = dict(resource.attributes) if resource.attributes else {} - - scope = getattr(span, "instrumentation_scope", None) - if scope: - result["instrumentation_scope"] = {"name": scope.name, "version": scope.version} - - return result - - -@pytest.mark.integration -class TestOpenAIMessageFormat: - """Capture real OpenAI Agents SDK span attributes after enrichment - and verify the A365 structured array message format.""" - - @pytest.fixture(autouse=True) - def setup_observability(self) -> None: - """Set up A365 observability with OpenAIAgentsTraceInstrumentor.""" - if not hasattr(TestOpenAIMessageFormat, "_exporter"): - configure( - service_name="integration-test-openai-message-format", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - exporter = SpanCapturingExporter() - provider = get_tracer_provider() - provider.add_span_processor( - _EnrichingBatchSpanProcessor( - exporter, - max_queue_size=100, - schedule_delay_millis=100, - max_export_batch_size=100, - ) - ) - - instrumentor = OpenAIAgentsTraceInstrumentor() - instrumentor.instrument() - - TestOpenAIMessageFormat._exporter = exporter - TestOpenAIMessageFormat._instrumentor = instrumentor - - self.exporter = TestOpenAIMessageFormat._exporter - self.exporter.spans.clear() - - @pytest.fixture - def openai_client(self, azure_openai_config: dict[str, Any]) -> AsyncAzureOpenAI: - """Create a real Azure OpenAI client.""" - return AsyncAzureOpenAI( - api_key=azure_openai_config["api_key"], - api_version=azure_openai_config["api_version"], - azure_endpoint=azure_openai_config["endpoint"], - ) - - def _find_message_spans(self) -> list[ReadableSpan]: - """Find exported spans that have gen_ai.input.messages.""" - get_tracer_provider().force_flush() - time.sleep(0.5) - return [ - s - for s in self.exporter.spans - if s.attributes and GEN_AI_INPUT_MESSAGES_KEY in s.attributes - ] - - @pytest.mark.asyncio - async def test_simple_chat_message_mapping( - self, - openai_client: AsyncAzureOpenAI, - azure_openai_config: dict[str, Any], - ) -> None: - """Simple chat: verify exported spans contain structured A365 messages.""" - agent = Agent( - name="TestAgent", - instructions="You are a helpful assistant. Reply in one sentence.", - model=OpenAIChatCompletionsModel( - model=azure_openai_config["deployment"], - openai_client=openai_client, - ), - ) - - result = await Runner.run(agent, "What is the capital of France?") - assert result is not None - assert len(result.final_output) > 0 - - # Print ALL spans as full JSON - print(f"\n=== All exported spans ({len(self.exporter.spans)}) ===") - for i, s in enumerate(self.exporter.spans): - span_json = _span_to_json(s) - print(f"\n--- SPAN {i} ---") - print(json.dumps(span_json, indent=2, default=str)) - - message_spans = self._find_message_spans() - assert len(message_spans) > 0, ( - f"No message spans found. All spans: {[s.name for s in self.exporter.spans]}" - ) - - # Verify at least one span has the structured array A365 format - found_structured = False - for span in message_spans: - attrs = dict(span.attributes or {}) - raw_input = attrs.get(GEN_AI_INPUT_MESSAGES_KEY) - if raw_input: - input_data = json.loads(raw_input) - if isinstance(input_data, list): - found_structured = True - messages = input_data - roles = [m["role"] for m in messages] - assert "user" in roles - for msg in messages: - for part in msg["parts"]: - assert "type" in part - - raw_output = attrs.get(GEN_AI_OUTPUT_MESSAGES_KEY) - if raw_output: - output_data = json.loads(raw_output) - if isinstance(output_data, list): - out_messages = output_data - assert out_messages[0]["role"] == "assistant" - assert any(p["type"] == "text" for p in out_messages[0]["parts"]) - - assert found_structured, "Expected at least one span with structured A365 message format" - - @pytest.mark.asyncio - async def test_tool_call_message_mapping( - self, - openai_client: AsyncAzureOpenAI, - azure_openai_config: dict[str, Any], - ) -> None: - """Tool-calling chat: verify tool_call and tool_call_response parts.""" - agent = Agent( - name="WeatherAgent", - instructions="You are a weather assistant. Always use the get_weather function.", - model=OpenAIChatCompletionsModel( - model=azure_openai_config["deployment"], - openai_client=openai_client, - ), - tools=[get_weather], - ) - - result = await Runner.run(agent, "What's the weather in Seattle?") - assert result is not None - assert len(result.final_output) > 0 - - # Print ALL spans as full JSON - print(f"\n=== All exported spans ({len(self.exporter.spans)}) ===") - for i, s in enumerate(self.exporter.spans): - span_json = _span_to_json(s) - print(f"\n--- SPAN {i} ---") - print(json.dumps(span_json, indent=2, default=str)) - - message_spans = self._find_message_spans() - assert len(message_spans) > 0 - - # Collect part types from exported (enriched) spans - part_types: set[str] = set() - for span in message_spans: - attrs = dict(span.attributes or {}) - for key in (GEN_AI_INPUT_MESSAGES_KEY, GEN_AI_OUTPUT_MESSAGES_KEY): - raw = attrs.get(key) - if not raw: - continue - data = json.loads(raw) - if isinstance(data, list): - messages = data - for msg in messages: - for part in msg.get("parts", []): - part_types.add(part.get("type", "")) - - print(f"\n Exported part types: {part_types}") - assert "text" in part_types, f"Expected text in exported parts: {part_types}" diff --git a/tests/observability/extensions/openai/integration/test_openai_trace_processor.py b/tests/observability/extensions/openai/integration/test_openai_trace_processor.py deleted file mode 100644 index a9a412e6..00000000 --- a/tests/observability/extensions/openai/integration/test_openai_trace_processor.py +++ /dev/null @@ -1,424 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import time - -import pytest -from microsoft_agents_a365.observability.core import configure, get_tracer_provider -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OPERATION_NAME_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, - GEN_AI_PROVIDER_NAME_KEY, - GEN_AI_REQUEST_MODEL_KEY, - INVOKE_AGENT_OPERATION_NAME, - TENANT_ID_KEY, -) -from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder -from microsoft_agents_a365.observability.extensions.openai.trace_instrumentor import ( - OpenAIAgentsTraceInstrumentor, -) - -try: - from agents import Agent, OpenAIChatCompletionsModel, Runner, function_tool - from openai import AsyncAzureOpenAI -except ImportError: - pytest.skip( - "OpenAI agents library and dependencies required for integration tests", - allow_module_level=True, - ) - - -@function_tool -def add_numbers(a: float, b: float) -> float: - """Add two numbers together. - - Args: - a: First number - b: Second number - - Returns: - The sum of a and b - """ - return a + b - - -@pytest.mark.integration -class TestOpenAITraceProcessorIntegration: - """Integration tests for OpenAI trace processor with real Azure OpenAI.""" - - def setup_method(self): - """Set up test method with mock exporter.""" - self.captured_spans = [] - self.mock_exporter = MockAgent365Exporter(self.captured_spans) - - def test_openai_trace_processor_integration(self, azure_openai_config, agent365_config): - """Test OpenAI trace processor with real Azure OpenAI call.""" - - # Configure observability - configure( - service_name="integration-test-service", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - # Get the tracer provider and add our mock exporter - provider = get_tracer_provider() - provider.add_span_processor(self.mock_exporter) - - # Initialize the instrumentor - instrumentor = OpenAIAgentsTraceInstrumentor() - instrumentor.instrument() - - try: - # Create Azure OpenAI client using API key (simpler for testing) - openai_client = AsyncAzureOpenAI( - api_key=azure_openai_config["api_key"], - api_version=azure_openai_config["api_version"], - azure_endpoint=azure_openai_config["endpoint"], - ) - - # Create agent with proper model configuration - agent = Agent( - name="TestAgent", - instructions="You are a helpful assistant.", - model=OpenAIChatCompletionsModel( - model=azure_openai_config["deployment"], openai_client=openai_client - ), - ) - - # Execute a simple prompt using async runner - import asyncio - - async def run_agent(): - result = await Runner.run(agent, "What can you do?") - return result.final_output - - response = asyncio.run(run_agent()) - - # Give some time for spans to be processed - time.sleep(1) - - # Verify that spans were captured - assert len(self.captured_spans) > 0, "No spans were captured" - - # Verify we have the expected span types - span_names = [span.name for span in self.captured_spans] - print(f"Captured spans: {span_names}") - - # Validate attributes on spans - self._validate_span_attributes(agent365_config) - - # Verify the response content - assert response is not None - assert len(response) > 0 - print(f"Agent response: {response}") - - finally: - # Clean up - instrumentor.uninstrument() - - def test_openai_trace_processor_with_tool_calls(self, azure_openai_config, agent365_config): - """Test OpenAI trace processor with tool calls.""" - - # Configure observability - configure( - service_name="integration-test-service-tools", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - # Get the tracer provider and add our mock exporter - provider = get_tracer_provider() - provider.add_span_processor(self.mock_exporter) - - # Initialize the instrumentor - instrumentor = OpenAIAgentsTraceInstrumentor() - instrumentor.instrument() - - try: - # Create Azure OpenAI client using API key - openai_client = AsyncAzureOpenAI( - api_key=azure_openai_config["api_key"], - api_version=azure_openai_config["api_version"], - azure_endpoint=azure_openai_config["endpoint"], - ) - - # Create agent with tool - agent = Agent( - name="MathAgent", - instructions="You are a helpful math assistant. Use the add_numbers tool to perform calculations.", - model=OpenAIChatCompletionsModel( - model=azure_openai_config["deployment"], openai_client=openai_client - ), - tools=[add_numbers], - ) - - # Execute a prompt that requires tool usage - import asyncio - - async def run_agent_with_tool(): - result = await Runner.run(agent, "What is 15 + 27?") - return result.final_output - - response = asyncio.run(run_agent_with_tool()) - - # Give some time for spans to be processed - time.sleep(1) - - # Verify that spans were captured - assert len(self.captured_spans) > 0, "No spans were captured" - - # Verify we have the expected span types - span_names = [span.name for span in self.captured_spans] - print(f"Captured spans with tools: {span_names}") - - # Validate attributes on spans including tool calls - self._validate_tool_span_attributes(agent365_config) - - # Verify the response content includes the calculation result - assert response is not None - assert len(response) > 0 - assert "42" in response # 15 + 27 = 42 - print(f"Agent response with tool: {response}") - - finally: - # Clean up - instrumentor.uninstrument() - - def test_invoke_agent_span_required_attributes(self, azure_openai_config, agent365_config): - """Test that invoke_agent span has all required attributes per schema.""" - - # Configure observability - configure( - service_name="integration-test-invoke-agent", - service_namespace="agent365-tests", - logger_name="test-logger", - ) - - # Get the tracer provider and add our mock exporter - provider = get_tracer_provider() - provider.add_span_processor(self.mock_exporter) - - # Initialize the instrumentor - instrumentor = OpenAIAgentsTraceInstrumentor() - instrumentor.instrument() - - try: - # Create Azure OpenAI client - openai_client = AsyncAzureOpenAI( - api_key=azure_openai_config["api_key"], - api_version=azure_openai_config["api_version"], - azure_endpoint=azure_openai_config["endpoint"], - ) - - # Create agent - agent = Agent( - name="TestAgent", - instructions="You are a helpful assistant. Answer briefly.", - model=OpenAIChatCompletionsModel( - model=azure_openai_config["deployment"], openai_client=openai_client - ), - ) - - # Execute agent wrapped with BaggageBuilder to provide required attributes - import asyncio - - async def run_agent(): - with ( - BaggageBuilder() - .agent_id("test-agent-id") - .agent_name("TestAgent") - .agentic_user_id("test-agent-auid") - .agentic_user_email("test-agent@test.com") - .agent_blueprint_id("test-blueprint-id") - .tenant_id("test-tenant-id") - .user_id("test-caller-id") - .user_name("Test Caller") - .user_email("test-caller@test.com") - .user_client_ip("127.0.0.1") - .conversation_id("test-conversation-id") - .channel_name("test-channel") - .build() - ): - result = await Runner.run(agent, "Say hello") - return result.final_output - - response = asyncio.run(run_agent()) - - # Give time for spans to be processed - time.sleep(1) - - # Find the invoke_agent span - invoke_agent_span = None - for span in self.captured_spans: - if span.name.startswith(INVOKE_AGENT_OPERATION_NAME): - invoke_agent_span = span - break - - assert invoke_agent_span is not None, "invoke_agent span not found" - attributes = dict(invoke_agent_span.attributes or {}) - - print(f"invoke_agent span attributes: {list(attributes.keys())}") - - # Validate REQUIRED attributes (must be present) - required_attributes = [ - GEN_AI_OPERATION_NAME_KEY, # "gen_ai.operation.name" - Set by SDK - GEN_AI_AGENT_ID_KEY, # "gen_ai.agent.id" - GEN_AI_AGENT_NAME_KEY, # "gen_ai.agent.name" - GEN_AI_INPUT_MESSAGES_KEY, # "gen_ai.input.messages" - GEN_AI_OUTPUT_MESSAGES_KEY, # "gen_ai.output.messages" - ] - - missing_required = [] - for attr in required_attributes: - if attr not in attributes: - missing_required.append(attr) - else: - print(f"✓ Required attribute present: {attr} = {str(attributes[attr])[:50]}...") - - assert len(missing_required) == 0, f"Missing required attributes: {missing_required}" - - # Validate operation name value - assert attributes[GEN_AI_OPERATION_NAME_KEY] == INVOKE_AGENT_OPERATION_NAME, ( - f"Expected operation name '{INVOKE_AGENT_OPERATION_NAME}', " - f"got '{attributes[GEN_AI_OPERATION_NAME_KEY]}'" - ) - - # Validate agent name matches - assert attributes[GEN_AI_AGENT_NAME_KEY] == "TestAgent", ( - f"Expected agent name 'TestAgent', got '{attributes[GEN_AI_AGENT_NAME_KEY]}'" - ) - - # Validate input/output messages are non-empty - assert attributes[GEN_AI_INPUT_MESSAGES_KEY], "Input messages should not be empty" - assert attributes[GEN_AI_OUTPUT_MESSAGES_KEY], "Output messages should not be empty" - - print("✓ All required invoke_agent span attributes validated") - print(f"Agent response: {response}") - - finally: - instrumentor.uninstrument() - - def _validate_span_attributes(self, agent365_config): - """Validate that spans have the expected attributes.""" - llm_spans_found = 0 - agent_spans_found = 0 - - for span in self.captured_spans: - attributes = dict(span.attributes or {}) - print(f"Span '{span.name}' attributes: {list(attributes.keys())}") - - # Check common attributes - if TENANT_ID_KEY in attributes: - assert attributes[TENANT_ID_KEY] == agent365_config["tenant_id"] - - if GEN_AI_AGENT_ID_KEY in attributes: - assert attributes[GEN_AI_AGENT_ID_KEY] == agent365_config["agent_id"] - - # Check for LLM spans (generation spans) - if ( - GEN_AI_PROVIDER_NAME_KEY in attributes - and attributes[GEN_AI_PROVIDER_NAME_KEY] == "openai" - ): - if GEN_AI_REQUEST_MODEL_KEY in attributes: - llm_spans_found += 1 - # Validate LLM span attributes - assert GEN_AI_REQUEST_MODEL_KEY in attributes - assert attributes[GEN_AI_REQUEST_MODEL_KEY] is not None - print(f"✓ Found LLM span with model: {attributes[GEN_AI_REQUEST_MODEL_KEY]}") - - # Check for input/output messages - if GEN_AI_INPUT_MESSAGES_KEY in attributes: - input_messages = attributes[GEN_AI_INPUT_MESSAGES_KEY] - assert input_messages is not None - print(f"✓ Input messages found: {input_messages[:100]}...") - - if GEN_AI_OUTPUT_MESSAGES_KEY in attributes: - output_messages = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - assert output_messages is not None - print(f"✓ Output messages found: {output_messages[:100]}...") - - # Check for agent spans - if "agent" in span.name.lower(): - agent_spans_found += 1 - print(f"✓ Found agent span: {span.name}") - - # Ensure we found at least some spans with telemetry data - assert len(self.captured_spans) > 0, "No spans were captured" - print(f"✓ Captured {len(self.captured_spans)} spans total") - print(f"✓ Found {llm_spans_found} LLM spans and {agent_spans_found} agent spans") - - def _validate_tool_span_attributes(self, agent365_config): - """Validate that spans have the expected attributes including tool calls.""" - llm_spans_found = 0 - agent_spans_found = 0 - tool_spans_found = 0 - - for span in self.captured_spans: - attributes = dict(span.attributes or {}) - print(f"Span '{span.name}' attributes: {list(attributes.keys())}") - - # Check common attributes - if TENANT_ID_KEY in attributes: - assert attributes[TENANT_ID_KEY] == agent365_config["tenant_id"] - - if GEN_AI_AGENT_ID_KEY in attributes: - assert attributes[GEN_AI_AGENT_ID_KEY] == agent365_config["agent_id"] - - # Check for LLM spans (generation spans) - if ( - GEN_AI_PROVIDER_NAME_KEY in attributes - and attributes[GEN_AI_PROVIDER_NAME_KEY] == "openai" - ): - if GEN_AI_REQUEST_MODEL_KEY in attributes: - llm_spans_found += 1 - print(f"✓ Found LLM span with model: {attributes[GEN_AI_REQUEST_MODEL_KEY]}") - - # Check for tool calls in messages - if GEN_AI_OUTPUT_MESSAGES_KEY in attributes: - output_messages = attributes[GEN_AI_OUTPUT_MESSAGES_KEY] - if "tool_calls" in output_messages: - print("✓ Found tool calls in LLM output messages") - - # Check for agent spans - if "agent" in span.name.lower(): - agent_spans_found += 1 - print(f"✓ Found agent span: {span.name}") - - # Check for tool execution spans - if "execute_tool" in span.name.lower() or "calculator_tool" in span.name.lower(): - tool_spans_found += 1 - print(f"✓ Found tool execution span: {span.name}") - - # Ensure we found the expected span types - assert len(self.captured_spans) > 0, "No spans were captured" - print(f"✓ Captured {len(self.captured_spans)} spans total") - print( - f"✓ Found {llm_spans_found} LLM spans, {agent_spans_found} agent spans, and {tool_spans_found} tool spans" - ) - - -class MockAgent365Exporter: - """Mock span processor that captures spans instead of sending them.""" - - def __init__(self, captured_spans): - self.captured_spans = captured_spans - - def on_start(self, span, parent_context=None): - """Called when a span starts.""" - pass - - def on_end(self, span): - """Called when a span ends.""" - self.captured_spans.append(span) - - def shutdown(self): - """Mock shutdown.""" - pass - - def force_flush(self, timeout_millis: int = 30000) -> bool: - """Mock force flush.""" - return True diff --git a/tests/observability/extensions/openai/test_message_mapper.py b/tests/observability/extensions/openai/test_message_mapper.py deleted file mode 100644 index 8d0ea2df..00000000 --- a/tests/observability/extensions/openai/test_message_mapper.py +++ /dev/null @@ -1,261 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Unit tests for the OpenAI message mapper.""" - -import json - -from microsoft_agents_a365.observability.extensions.openai.message_mapper import ( - map_input_messages, - map_output_messages, -) - - -class TestMapInputMessages: - """Tests for map_input_messages.""" - - def test_empty_string_returns_none(self) -> None: - assert map_input_messages("") is None - - def test_whitespace_only_returns_none(self) -> None: - assert map_input_messages(" ") is None - - def test_plain_string_wraps_as_user_message(self) -> None: - result = map_input_messages("Hello world") - assert result is not None - data = json.loads(result) - - assert len(data) == 1 - assert data[0]["role"] == "user" - assert data[0]["parts"][0]["type"] == "text" - assert data[0]["parts"][0]["content"] == "Hello world" - - def test_chat_completions_format(self) -> None: - """Standard chat completions format with system + user messages.""" - raw = json.dumps([ - {"role": "system", "content": "You are helpful."}, - {"role": "user", "content": "Hi there"}, - ]) - result = map_input_messages(raw) - assert result is not None - data = json.loads(result) - - assert len(data) == 2 - assert data[0]["role"] == "system" - assert data[0]["parts"][0]["content"] == "You are helpful." - assert data[1]["role"] == "user" - assert data[1]["parts"][0]["content"] == "Hi there" - - def test_chat_completions_with_tool_calls(self) -> None: - """Messages with assistant tool_calls and tool response.""" - raw = json.dumps([ - {"role": "user", "content": "What is 2+2?"}, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_123", - "function": {"name": "add", "arguments": '{"a":2,"b":2}'}, - } - ], - }, - {"role": "tool", "content": "4", "tool_call_id": "call_123"}, - ]) - result = map_input_messages(raw) - assert result is not None - data = json.loads(result) - - assert len(data) == 3 - - # User message - assert data[0]["role"] == "user" - assert data[0]["parts"][0]["type"] == "text" - - # Assistant with tool call - assert data[1]["role"] == "assistant" - assert data[1]["parts"][0]["type"] == "tool_call" - assert data[1]["parts"][0]["name"] == "add" - assert data[1]["parts"][0]["id"] == "call_123" - - # Tool response - assert data[2]["role"] == "tool" - assert data[2]["parts"][0]["type"] == "tool_call_response" - assert data[2]["parts"][0]["id"] == "call_123" - assert data[2]["parts"][0]["response"] == "4" - - def test_response_input_item_param_format(self) -> None: - """ResponseInputItemParam format with typed items.""" - raw = json.dumps([ - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "Hello"}], - }, - { - "type": "function_call", - "name": "get_weather", - "call_id": "fc_1", - "arguments": '{"city":"Seattle"}', - }, - {"type": "function_call_output", "call_id": "fc_1", "output": "Sunny, 22C"}, - ]) - result = map_input_messages(raw) - assert result is not None - data = json.loads(result) - - assert len(data) == 3 - - # Message - assert data[0]["role"] == "user" - assert data[0]["parts"][0]["type"] == "text" - - # Function call - assert data[1]["role"] == "assistant" - assert data[1]["parts"][0]["type"] == "tool_call" - assert data[1]["parts"][0]["name"] == "get_weather" - - # Function call output - assert data[2]["role"] == "tool" - assert data[2]["parts"][0]["type"] == "tool_call_response" - assert data[2]["parts"][0]["response"] == "Sunny, 22C" - - def test_message_without_type_field(self) -> None: - """Messages without explicit 'type' field (EasyInputMessageParam).""" - raw = json.dumps([ - {"role": "user", "content": "Hello"}, - ]) - result = map_input_messages(raw) - assert result is not None - data = json.loads(result) - assert data[0]["role"] == "user" - - def test_invalid_json_wraps_as_plain_text(self) -> None: - result = map_input_messages("not json {") - assert result is not None - data = json.loads(result) - - assert data[0]["parts"][0]["content"] == "not json {" - - def test_empty_list_returns_none(self) -> None: - assert map_input_messages("[]") is None - - -class TestMapOutputMessages: - """Tests for map_output_messages.""" - - def test_empty_string_returns_none(self) -> None: - assert map_output_messages("") is None - - def test_plain_string_wraps_as_assistant(self) -> None: - result = map_output_messages("The answer is 42.") - assert result is not None - data = json.loads(result) - - assert data[0]["role"] == "assistant" - assert data[0]["parts"][0]["content"] == "The answer is 42." - - def test_chat_completions_output(self) -> None: - """Standard chat completions output with finish_reason.""" - raw = json.dumps([ - { - "role": "assistant", - "content": "Paris is the capital.", - "finish_reason": "stop", - } - ]) - result = map_output_messages(raw) - assert result is not None - data = json.loads(result) - - assert len(data) == 1 - msg = data[0] - assert msg["role"] == "assistant" - assert msg["parts"][0]["type"] == "text" - assert msg["parts"][0]["content"] == "Paris is the capital." - assert msg["finish_reason"] == "stop" - - def test_chat_completions_with_tool_calls(self) -> None: - """Output with tool_calls.""" - raw = json.dumps([ - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_abc", - "function": {"name": "search", "arguments": '{"q":"test"}'}, - } - ], - "finish_reason": "tool_calls", - } - ]) - result = map_output_messages(raw) - assert result is not None - data = json.loads(result) - msg = data[0] - assert msg["role"] == "assistant" - assert msg["parts"][0]["type"] == "tool_call" - assert msg["parts"][0]["name"] == "search" - assert msg["finish_reason"] == "tool_calls" - - def test_response_json_format(self) -> None: - """Full OpenAI Response JSON (from model_dump_json).""" - raw = json.dumps({ - "id": "resp_123", - "model": "gpt-4o", - "output": [ - { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello!"}], - "status": "completed", - } - ], - }) - result = map_output_messages(raw) - assert result is not None - data = json.loads(result) - - msg = data[0] - assert msg["role"] == "assistant" - assert msg["parts"][0]["type"] == "text" - assert msg["parts"][0]["content"] == "Hello!" - - def test_response_json_with_function_call(self) -> None: - """Response JSON with function_call output item.""" - raw = json.dumps({ - "id": "resp_456", - "model": "gpt-4o", - "output": [ - { - "type": "function_call", - "name": "get_weather", - "call_id": "fc_1", - "arguments": '{"city":"NYC"}', - } - ], - }) - result = map_output_messages(raw) - assert result is not None - data = json.loads(result) - msg = data[0] - assert msg["role"] == "assistant" - assert msg["parts"][0]["type"] == "tool_call" - assert msg["parts"][0]["name"] == "get_weather" - assert msg["finish_reason"] == "tool_call" - - def test_response_json_without_output_returns_none(self) -> None: - """Response JSON without output field.""" - raw = json.dumps({"id": "resp_789", "model": "gpt-4o"}) - assert map_output_messages(raw) is None - - def test_empty_list_returns_none(self) -> None: - assert map_output_messages("[]") is None - - def test_invalid_json_wraps_as_plain_text(self) -> None: - result = map_output_messages("bad json") - assert result is not None - data = json.loads(result) - - assert data[0]["role"] == "assistant" diff --git a/tests/observability/extensions/openai/test_prompt_suppression.py b/tests/observability/extensions/openai/test_prompt_suppression.py deleted file mode 100644 index 1d198885..00000000 --- a/tests/observability/extensions/openai/test_prompt_suppression.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from unittest.mock import Mock - -from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import ( - _EnrichingBatchSpanProcessor, -) - - -class TestPromptSuppressionConfiguration(unittest.TestCase): - """Unit tests for prompt suppression configuration in the core SDK.""" - - def test_processor_default_suppression_is_false(self): - """Test that the default value for suppress_invoke_agent_input is False in processor.""" - mock_exporter = Mock() - processor = _EnrichingBatchSpanProcessor(mock_exporter) - - self.assertFalse( - processor._suppress_invoke_agent_input, - "Default value for suppress_invoke_agent_input should be False", - ) - processor.shutdown() - - def test_processor_can_enable_suppression(self): - """Test that suppression can be enabled via processor constructor.""" - mock_exporter = Mock() - processor = _EnrichingBatchSpanProcessor(mock_exporter, suppress_invoke_agent_input=True) - - self.assertTrue( - processor._suppress_invoke_agent_input, - "suppress_invoke_agent_input should be True when explicitly set", - ) - processor.shutdown() - - -def run_tests(): - """Run all prompt suppression configuration tests.""" - print("Running prompt suppression configuration tests...") - print("=" * 80) - - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(TestPromptSuppressionConfiguration) - - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - - print("\n" + "=" * 80) - print("Test Summary:") - print(f"Tests run: {result.testsRun}") - print(f"Failures: {len(result.failures)}") - print(f"Errors: {len(result.errors)}") - - if result.wasSuccessful(): - print("All tests passed!") - return True - else: - print("Some tests failed. Check output above.") - return False - - -if __name__ == "__main__": - success = run_tests() - exit(0 if success else 1) diff --git a/tests/observability/extensions/openai/test_trace_processor_bounded.py b/tests/observability/extensions/openai/test_trace_processor_bounded.py deleted file mode 100644 index 69226222..00000000 --- a/tests/observability/extensions/openai/test_trace_processor_bounded.py +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -Standalone test for bounded tool calls functionality. -This test can be run without installing the full package. -""" - -from collections import OrderedDict - - -def capture_tool_call_ids_test(output_list, pending_tool_calls, max_size=1000): - """Test version of capture_tool_call_ids function.""" - if not output_list: - return - try: - for msg in output_list: - if isinstance(msg, dict) and msg.get("role") == "assistant": - tool_calls = msg.get("tool_calls") - if tool_calls: - for tc in tool_calls: - if isinstance(tc, dict): - call_id = tc.get("id") - func = tc.get("function", {}) - func_name = func.get("name") if isinstance(func, dict) else None - func_args = func.get("arguments", "") if isinstance(func, dict) else "" - if call_id and func_name: - key = f"{func_name}:{func_args}" - pending_tool_calls[key] = call_id - # Cap the size of the dict to prevent unbounded growth - while len(pending_tool_calls) > max_size: - pending_tool_calls.popitem(last=False) - except Exception: - pass - - -def test_bounded_size(): - """Test that the bounded size logic works correctly.""" - pending_tool_calls = OrderedDict() - max_size = 10 - - print("Testing bounded size functionality...") - print(f"Max size: {max_size}") - - # Create tool calls that exceed max_size - for i in range(15): - output_list = [ - { - "role": "assistant", - "tool_calls": [ - { - "id": f"call_{i}", - "function": { - "name": f"function_{i}", - "arguments": f'{{"arg": {i}}}', - }, - } - ], - } - ] - capture_tool_call_ids_test(output_list, pending_tool_calls, max_size) - print(f"After adding call {i}: size = {len(pending_tool_calls)}") - - # Verify the size does not exceed max_size - assert len(pending_tool_calls) <= max_size, ( - f"Size {len(pending_tool_calls)} exceeds max {max_size}" - ) - assert len(pending_tool_calls) == max_size, ( - f"Size {len(pending_tool_calls)} should equal max {max_size}" - ) - - print(f"\n✅ Final size: {len(pending_tool_calls)} (max: {max_size})") - - # Verify that the oldest entries were removed (FIFO behavior) - print("\nVerifying FIFO behavior...") - for i in range(5): - key = f'function_{i}:{{"arg": {i}}}' - assert key not in pending_tool_calls, f"Old entry {key} should have been removed" - print(f"✅ Entry {i} was correctly removed (oldest)") - - # The last 10 entries should still be present - for i in range(5, 15): - key = f'function_{i}:{{"arg": {i}}}' - assert key in pending_tool_calls, f"Recent entry {key} should be present" - assert pending_tool_calls[key] == f"call_{i}", f"Call ID mismatch for {key}" - print(f"✅ Entry {i} is present with correct call_id") - - print("\n✅ All tests passed! Bounded size logic works correctly.") - return True - - -def test_single_tool_call(): - """Test storing a single tool call.""" - pending_tool_calls = OrderedDict() - max_size = 10 - - print("\nTesting single tool call...") - output_list = [ - { - "role": "assistant", - "tool_calls": [ - { - "id": "call_123", - "function": { - "name": "add_numbers", - "arguments": '{"a": 5, "b": 10}', - }, - } - ], - } - ] - capture_tool_call_ids_test(output_list, pending_tool_calls, max_size) - - assert len(pending_tool_calls) == 1 - key = 'add_numbers:{"a": 5, "b": 10}' - assert key in pending_tool_calls - assert pending_tool_calls[key] == "call_123" - - print("✅ Single tool call test passed!") - return True - - -if __name__ == "__main__": - print("=" * 70) - print("Running Bounded Tool Calls Tests") - print("=" * 70) - - try: - test_bounded_size() - test_single_tool_call() - print("\n" + "=" * 70) - print("🎉 All tests passed successfully!") - print("=" * 70) - except AssertionError as e: - print(f"\n❌ Test failed: {e}") - exit(1) - except Exception as e: - print(f"\n❌ Unexpected error: {e}") - exit(1) diff --git a/tests/observability/extensions/openai/test_wrapper_openaiagents.py b/tests/observability/extensions/openai/test_wrapper_openaiagents.py deleted file mode 100644 index ffc14a00..00000000 --- a/tests/observability/extensions/openai/test_wrapper_openaiagents.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from types import SimpleNamespace -from unittest.mock import MagicMock - -from microsoft_agents_a365.observability.core import configure -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_RESPONSE_MODEL_KEY, - GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, -) -from microsoft_agents_a365.observability.extensions.openai import OpenAIAgentsTraceInstrumentor -from microsoft_agents_a365.observability.extensions.openai.utils import ( - get_attributes_from_response, - get_attributes_from_usage, -) - - -class TestOpenAIModelObservationAttributes(unittest.TestCase): - """Unit tests for model observation attribute mapping.""" - - def test_response_model_uses_response_attribute(self): - """Test that the served model is emitted as the response model.""" - response = MagicMock() - response.tools = [] - response.usage = None - response.output = [] - response.instructions = None - response.model = "gpt-4.1-2025-04-14" - response.model_dump.return_value = {} - - attributes = dict(get_attributes_from_response(response)) - - self.assertEqual(attributes[GEN_AI_RESPONSE_MODEL_KEY], "gpt-4.1-2025-04-14") - - def test_cached_tokens_use_standard_attribute(self): - """Test that cached input tokens use the model observation attribute.""" - usage = SimpleNamespace( - input_tokens=100, - output_tokens=20, - total_tokens=120, - input_tokens_details=SimpleNamespace(cached_tokens=40), - output_tokens_details=SimpleNamespace(reasoning_tokens=5), - ) - - attributes = dict(get_attributes_from_usage(usage)) - - self.assertEqual(attributes[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY], 40) - - -class TestOpenAIAgentsTraceInstrumentor(unittest.TestCase): - """Unit tests for OpenAIAgentsTraceInstrumentor class.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - # Configure Microsoft Agent 365 for testing - configure( - service_name="test-service-openaiAgents", - service_namespace="test-namespace-openaiAgents", - ) - - def test_instrumentor_initialization(self): - """Test 1: Verify OpenAIAgentsTraceInstrumentor can be initialized successfully.""" - try: - # Test basic initialization - instrumentor = OpenAIAgentsTraceInstrumentor() - - # Verify the object was created - self.assertIsNotNone(instrumentor) - self.assertIsInstance(instrumentor, OpenAIAgentsTraceInstrumentor) - - # Check if it has expected attributes/methods - self.assertTrue(hasattr(instrumentor, "__init__")) - - print("✅ Test 1 passed: OpenAIAgentsTraceInstrumentor initialized successfully") - - except Exception as e: - self.fail(f"OpenAIAgentsTraceInstrumentor initialization failed: {e}") - - def test_instrumentor_methods_exist(self): - """Test 2: Verify OpenAIAgentsTraceInstrumentor has expected methods.""" - instrumentor = OpenAIAgentsTraceInstrumentor() - - # Test for common instrumentor methods that might exist - expected_methods = ["__init__", "_instrument"] - - for method_name in expected_methods: - with self.subTest(method=method_name): - self.assertTrue( - hasattr(instrumentor, method_name), - f"Method '{method_name}' should exist on OpenAIAgentsTraceInstrumentor", - ) - - # Test that the object responds to dir() without error - methods_and_attrs = dir(instrumentor) - self.assertIsInstance(methods_and_attrs, list) - self.assertGreater(len(methods_and_attrs), 0) - - print("✅ Test 2 passed: OpenAIAgentsTraceInstrumentor has expected methods") - - -class TestAgent365InstrumentorIntegration(unittest.TestCase): - """Integration tests for the instrumentor with the broader Microsoft Agent 365 system.""" - - def setUp(self): - """Set up each test with a fresh Microsoft Agent 365 configuration.""" - configure( - service_name="integration-test-service", - service_namespace="integration-test-namespace", - ) - - def test_instrumentor_with_Agent365_configured(self): - """Test instrumentor behavior when Microsoft Agent 365 is properly configured.""" - from microsoft_agents_a365.observability.core import get_tracer, is_configured - - # Verify Microsoft Agent 365 is configured - self.assertTrue(is_configured()) - - # Get tracer to ensure it works - tracer = get_tracer() - self.assertIsNotNone(tracer) - - # Now create instrumentor - instrumentor = OpenAIAgentsTraceInstrumentor() - self.assertIsNotNone(instrumentor) - - print("✅ Integration test passed: Instrumentor works with configured Microsoft Agent 365") - - -def run_comprehensive_tests(): - """Run all tests with detailed output.""" - print("🧪 Running comprehensive Microsoft Agent 365 OpenAI Agents Instrumentor tests...") - print("=" * 80) - - # Create test suite - loader = unittest.TestLoader() - suite = unittest.TestSuite() - - # Add test classes - suite.addTests(loader.loadTestsFromTestCase(TestOpenAIAgentsTraceInstrumentor)) - suite.addTests(loader.loadTestsFromTestCase(TestAgent365InstrumentorIntegration)) - - # Run tests - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - - # Summary - print("\n" + "=" * 80) - print("🏁 Test Summary:") - print(f"Tests run: {result.testsRun}") - print(f"Failures: {len(result.failures)}") - print(f"Errors: {len(result.errors)}") - - if result.wasSuccessful(): - print("🎉 All tests passed!") - return True - else: - print("🔧 Some tests failed. Check output above.") - return False - - -if __name__ == "__main__": - # Run comprehensive tests - success = run_comprehensive_tests() - exit(0 if success else 1) diff --git a/tests/observability/extensions/semantickernel/__init__.py b/tests/observability/extensions/semantickernel/__init__.py deleted file mode 100644 index 1d8b4766..00000000 --- a/tests/observability/extensions/semantickernel/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/extensions/semantickernel/test_span_enricher.py b/tests/observability/extensions/semantickernel/test_span_enricher.py deleted file mode 100644 index c8817838..00000000 --- a/tests/observability/extensions/semantickernel/test_span_enricher.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for Semantic Kernel span enricher.""" - -import unittest -from unittest.mock import Mock - -from microsoft_agents_a365.observability.core.constants import ( - GEN_AI_INPUT_MESSAGES_KEY, - GEN_AI_OUTPUT_MESSAGES_KEY, -) -from microsoft_agents_a365.observability.extensions.semantickernel.span_enricher import ( - enrich_semantic_kernel_span, -) - - -class TestSemanticKernelSpanEnricher(unittest.TestCase): - """Test suite for enrich_semantic_kernel_span function.""" - - def test_invoke_agent_span_extracts_content_from_messages(self): - """Test that invoke_agent spans have content extracted from input/output messages.""" - # Create a mock span with invoke_agent name and message attributes - mock_span = Mock() - mock_span.name = "invoke_agent test-agent" - mock_span.attributes = { - "gen_ai.agent.invocation_input": '[{"role": "user", "content": "Hello"}]', - "gen_ai.agent.invocation_output": '[{"role": "assistant", "content": "Hi there!"}]', - } - - # Enrich the span - enriched = enrich_semantic_kernel_span(mock_span) - - # Verify it returns an EnrichedReadableSpan with extracted content - self.assertNotEqual(enriched, mock_span) - attributes = enriched.attributes - # extract_content_as_string_list returns a JSON string - self.assertEqual(attributes[GEN_AI_INPUT_MESSAGES_KEY], '["Hello"]') - self.assertEqual(attributes[GEN_AI_OUTPUT_MESSAGES_KEY], '["Hi there!"]') - - def test_non_matching_span_returns_original(self): - """Test that spans not matching invoke_agent or execute_tool are returned unchanged.""" - # Create a mock span with a different operation name - mock_span = Mock() - mock_span.name = "some_other_operation" - mock_span.attributes = { - "some_key": "some_value", - } - - # Enrich the span - result = enrich_semantic_kernel_span(mock_span) - - # Verify it returns the original span unchanged - self.assertEqual(result, mock_span) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py b/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py deleted file mode 100644 index ff475dbb..00000000 --- a/tests/observability/extensions/semantickernel/test_wrapper_semantic_kernel.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -from unittest.mock import MagicMock, patch - -from microsoft_agents_a365.observability.core import configure -from microsoft_agents_a365.observability.extensions.semantickernel.span_processor import ( - SemanticKernelSpanProcessor, -) -from microsoft_agents_a365.observability.extensions.semantickernel.trace_instrumentor import ( - SemanticKernelInstrumentor, -) - - -class TestSemanticKernelInstrumentor(unittest.TestCase): - """Unit tests for SemanticKernelInstrumentor class.""" - - @classmethod - def setUpClass(cls): - """Set up test environment once for all tests.""" - # Configure Microsoft Agent 365 for testing - configure( - service_name="test-service-semantic-kernel", - service_namespace="test-namespace-semantic-kernel", - ) - - def test_instrumentor_initialization_and_dependencies(self): - """Test instrumentor initialization and dependency verification.""" - # Test basic initialization - instrumentor = SemanticKernelInstrumentor() - self.assertIsNotNone(instrumentor) - self.assertIsInstance(instrumentor, SemanticKernelInstrumentor) - - # Test dependencies - dependencies = instrumentor.instrumentation_dependencies() - self.assertIsInstance(dependencies, (list, tuple)) - - # Verify it contains semantic-kernel dependency - dependency_strings = list(dependencies) - semantic_kernel_deps = [dep for dep in dependency_strings if "semantic-kernel" in dep] - self.assertGreater( - len(semantic_kernel_deps), 0, "Should have at least one semantic-kernel dependency" - ) - - @patch( - "microsoft_agents_a365.observability.extensions.semantickernel.trace_instrumentor.get_tracer_provider" - ) - def test_instrumentor_adds_span_processor(self, mock_get_tracer_provider): - """Test instrumentor adds SemanticKernelSpanProcessor to tracer provider.""" - # Mock the tracer provider - mock_provider = MagicMock() - mock_get_tracer_provider.return_value = mock_provider - - # Create and instrument - instrumentor = SemanticKernelInstrumentor() - instrumentor._instrument() - - # Verify add_span_processor was called - mock_provider.add_span_processor.assert_called_once() - - # Verify the processor added is a SemanticKernelSpanProcessor - args, kwargs = mock_provider.add_span_processor.call_args - processor = args[0] if args else kwargs.get("processor") - self.assertIsInstance(processor, SemanticKernelSpanProcessor) - - def test_span_processor_chat_span_processing(self): - """Test SpanProcessor correctly processes chat spans and ignores others.""" - processor = SemanticKernelSpanProcessor(service_name="test-service") - - # Test processor initialization - self.assertIsNotNone(processor) - self.assertEqual(processor.service_name, "test-service") - - # Test chat span processing - mock_chat_span = MagicMock() - mock_chat_span.name = "chat.completions" - mock_parent_context = MagicMock() - - processor.on_start(mock_chat_span, mock_parent_context) - - # Verify set_attribute and update_name were called - mock_chat_span.set_attribute.assert_called_once() - mock_chat_span.update_name.assert_called_once() - - # Verify the updated name contains "chat" - args, kwargs = mock_chat_span.update_name.call_args - updated_name = args[0] if args else kwargs.get("name") - self.assertIn("chat", updated_name.lower()) - - # Test non-chat span (should be ignored) - mock_non_chat_span = MagicMock() - mock_non_chat_span.name = "function.call" - - processor.on_start(mock_non_chat_span, mock_parent_context) - - # Verify set_attribute and update_name were NOT called for non-chat span - mock_non_chat_span.set_attribute.assert_not_called() - mock_non_chat_span.update_name.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/observability/hosting/__init__.py b/tests/observability/hosting/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/tests/observability/hosting/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/hosting/middleware/__init__.py b/tests/observability/hosting/middleware/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/tests/observability/hosting/middleware/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/hosting/middleware/test_baggage_middleware.py b/tests/observability/hosting/middleware/test_baggage_middleware.py deleted file mode 100644 index 4c3e2574..00000000 --- a/tests/observability/hosting/middleware/test_baggage_middleware.py +++ /dev/null @@ -1,211 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import json -from unittest.mock import MagicMock - -import pytest -from microsoft_agents.activity import ( - Activity, - ActivityEventNames, - ActivityTypes, - ChannelAccount, - ChannelId, - ConversationAccount, -) -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - TENANT_ID_KEY, - USER_ID_KEY, -) -from microsoft_agents_a365.observability.hosting.middleware.baggage_middleware import ( - BaggageMiddleware, -) -from opentelemetry import baggage - - -def _make_turn_context( - activity_type: str = "message", - activity_name: str | None = None, - text: str = "Hello", -) -> TurnContext: - """Create a TurnContext with a test activity.""" - kwargs: dict = { - "type": activity_type, - "text": text, - "from_property": ChannelAccount( - aad_object_id="caller-id", - name="Caller", - agentic_user_id="caller-upn", - tenant_id="tenant-id", - ), - "recipient": ChannelAccount( - tenant_id="tenant-123", - role="user", - name="Agent", - ), - "conversation": ConversationAccount(id="conv-id"), - "service_url": "https://example.com", - "channel_id": "test-channel", - } - if activity_name is not None: - kwargs["name"] = activity_name - activity = Activity(**kwargs) - adapter = MagicMock() - return TurnContext(adapter, activity) - - -def _make_channel_data_turn_context( - channel_id: ChannelId | str = "msteams", - channel_data: object | None = None, -) -> TurnContext: - """Create a TurnContext with channel_data for testing.""" - activity = Activity( - type="message", - text="Hello", - from_property=ChannelAccount( - aad_object_id="caller-id", - name="Caller", - ), - recipient=ChannelAccount( - tenant_id="tenant-123", - name="Agent", - ), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", - channel_id=channel_id, - channel_data=channel_data, - ) - adapter = MagicMock() - return TurnContext(adapter, activity) - - -@pytest.mark.asyncio -async def test_baggage_middleware_propagates_baggage(): - """BaggageMiddleware should set baggage context for the downstream logic.""" - middleware = BaggageMiddleware() - ctx = _make_turn_context() - - captured_caller_id = None - captured_tenant_id = None - - async def logic(): - nonlocal captured_caller_id, captured_tenant_id - captured_caller_id = baggage.get_baggage(USER_ID_KEY) - captured_tenant_id = baggage.get_baggage(TENANT_ID_KEY) - - await middleware.on_turn(ctx, logic) - - assert captured_caller_id == "caller-id" - assert captured_tenant_id == "tenant-123" - - -@pytest.mark.asyncio -async def test_baggage_middleware_skips_async_reply(): - """BaggageMiddleware should skip baggage setup for ContinueConversation events.""" - middleware = BaggageMiddleware() - ctx = _make_turn_context( - activity_type=ActivityTypes.event, - activity_name=ActivityEventNames.continue_conversation, - ) - - logic_called = False - captured_caller_id = None - - async def logic(): - nonlocal logic_called, captured_caller_id - logic_called = True - captured_caller_id = baggage.get_baggage(USER_ID_KEY) - - await middleware.on_turn(ctx, logic) - - assert logic_called is True - # Baggage should NOT be set because the middleware skipped it - assert captured_caller_id is None - - -@pytest.mark.asyncio -async def test_baggage_middleware_extracts_product_context_from_channel_data(): - """BaggageMiddleware should extract productContext from channel_data when sub_channel is not set.""" - - middleware = BaggageMiddleware() - ctx = _make_channel_data_turn_context( - channel_id=ChannelId(channel="msteams"), # No sub_channel - channel_data={"productContext": "COPILOT"}, - ) - - captured_channel_link = None - - async def logic(): - nonlocal captured_channel_link - captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) - - await middleware.on_turn(ctx, logic) - - assert captured_channel_link == "COPILOT" - - -@pytest.mark.asyncio -async def test_baggage_middleware_sub_channel_takes_precedence_over_product_context(): - """BaggageMiddleware should use sub_channel when both sub_channel and productContext are present.""" - - middleware = BaggageMiddleware() - ctx = _make_channel_data_turn_context( - channel_id=ChannelId(channel="msteams", sub_channel="teams-subchannel"), - channel_data={"productContext": "COPILOT"}, # Should be ignored - ) - - captured_channel_link = None - - async def logic(): - nonlocal captured_channel_link - captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) - - await middleware.on_turn(ctx, logic) - - # sub_channel should take precedence, productContext should be ignored - assert captured_channel_link == "teams-subchannel" - - -@pytest.mark.asyncio -async def test_baggage_middleware_extracts_product_context_from_json_string_channel_data(): - """BaggageMiddleware should extract productContext from channel_data when it's a JSON string.""" - - middleware = BaggageMiddleware() - ctx = _make_channel_data_turn_context( - channel_id=ChannelId(channel="msteams"), # No sub_channel - channel_data=json.dumps({"productContext": "COPILOT"}), # JSON string - ) - - captured_channel_link = None - - async def logic(): - nonlocal captured_channel_link - captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) - - await middleware.on_turn(ctx, logic) - - assert captured_channel_link == "COPILOT" - - -@pytest.mark.asyncio -async def test_baggage_middleware_handles_invalid_json_channel_data_gracefully(): - """BaggageMiddleware should handle invalid JSON in channel_data gracefully without setting baggage.""" - - middleware = BaggageMiddleware() - ctx = _make_channel_data_turn_context( - channel_id=ChannelId(channel="msteams"), # No sub_channel - channel_data="not valid json", # Non-JSON string - ) - - captured_channel_link = None - - async def logic(): - nonlocal captured_channel_link - captured_channel_link = baggage.get_baggage(CHANNEL_LINK_KEY) - - await middleware.on_turn(ctx, logic) - - # Should not set ChannelLink, should fail gracefully - assert captured_channel_link is None diff --git a/tests/observability/hosting/middleware/test_observability_hosting_manager.py b/tests/observability/hosting/middleware/test_observability_hosting_manager.py deleted file mode 100644 index a3423211..00000000 --- a/tests/observability/hosting/middleware/test_observability_hosting_manager.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from unittest.mock import MagicMock - -import pytest -from microsoft_agents_a365.observability.hosting.middleware.baggage_middleware import ( - BaggageMiddleware, -) -from microsoft_agents_a365.observability.hosting.middleware.observability_hosting_manager import ( - ObservabilityHostingManager, - ObservabilityHostingOptions, -) -from microsoft_agents_a365.observability.hosting.middleware.output_logging_middleware import ( - OutputLoggingMiddleware, -) - - -@pytest.fixture(autouse=True) -def _reset_singleton(): - """Reset the singleton before and after each test.""" - ObservabilityHostingManager._reset() - yield - ObservabilityHostingManager._reset() - - -def test_configure_is_singleton(): - """configure() should return an ObservabilityHostingManager and be a singleton.""" - middleware_set = MagicMock() - options = ObservabilityHostingOptions() - first = ObservabilityHostingManager.configure(middleware_set, options) - assert isinstance(first, ObservabilityHostingManager) - second = ObservabilityHostingManager.configure(middleware_set, options) - assert first is second - - -@pytest.mark.parametrize( - "enable_baggage,enable_output_logging,expected_types", - [ - (True, False, [BaggageMiddleware]), - (True, True, [BaggageMiddleware, OutputLoggingMiddleware]), - (False, True, [OutputLoggingMiddleware]), - (False, False, []), - ], - ids=["default_baggage_only", "both_enabled", "output_only", "none"], -) -def test_configure_registers_expected_middlewares( - enable_baggage, enable_output_logging, expected_types -): - """configure() should register the correct middlewares based on options.""" - middleware_set = MagicMock() - options = ObservabilityHostingOptions( - enable_baggage=enable_baggage, enable_output_logging=enable_output_logging - ) - ObservabilityHostingManager.configure(middleware_set, options) - - assert middleware_set.use.call_count == len(expected_types) - for call, expected_type in zip(middleware_set.use.call_args_list, expected_types, strict=True): - assert isinstance(call[0][0], expected_type) - - -@pytest.mark.parametrize( - "middleware_set,options,match", - [ - (None, ObservabilityHostingOptions(), "middleware_set must not be None"), - (MagicMock(), None, "options must not be None"), - ], - ids=["none_middleware_set", "none_options"], -) -def test_configure_raises_on_none(middleware_set, options, match): - """configure() should raise TypeError when required args are None.""" - with pytest.raises(TypeError, match=match): - ObservabilityHostingManager.configure(middleware_set, options) diff --git a/tests/observability/hosting/middleware/test_output_logging_middleware.py b/tests/observability/hosting/middleware/test_output_logging_middleware.py deleted file mode 100644 index fc1619b4..00000000 --- a/tests/observability/hosting/middleware/test_output_logging_middleware.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from microsoft_agents.activity import ( - Activity, - ChannelAccount, - ConversationAccount, -) -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents_a365.observability.hosting.middleware.output_logging_middleware import ( - A365_PARENT_TRACEPARENT_KEY, - OutputLoggingMiddleware, -) - - -def _make_turn_context( - activity_type: str = "message", - activity_name: str | None = None, - text: str = "Hello", - recipient_tenant_id: str = "tenant-123", - recipient_agentic_app_id: str = "agent-app-id", -) -> TurnContext: - """Create a TurnContext with a test activity.""" - kwargs: dict = { - "type": activity_type, - "text": text, - "from_property": ChannelAccount( - aad_object_id="caller-id", - name="Caller", - agentic_user_id="caller-upn", - tenant_id="caller-tenant-id", - ), - "recipient": ChannelAccount( - tenant_id=recipient_tenant_id, - role="agenticAppInstance", - name="Agent One", - agentic_app_id=recipient_agentic_app_id, - aad_object_id="agent-auid", - agentic_user_id="agent-upn", - ), - "conversation": ConversationAccount(id="conv-id"), - "service_url": "https://example.com", - "channel_id": "test-channel", - } - if activity_name is not None: - kwargs["name"] = activity_name - activity = Activity(**kwargs) - adapter = MagicMock() - return TurnContext(adapter, activity) - - -@pytest.mark.asyncio -async def test_output_logging_registers_send_handler(): - """OutputLoggingMiddleware should register an on_send_activities handler.""" - middleware = OutputLoggingMiddleware() - ctx = _make_turn_context() - - initial_handler_count = len(ctx._on_send_activities) - - async def logic(): - pass - - await middleware.on_turn(ctx, logic) - - assert len(ctx._on_send_activities) == initial_handler_count + 1 - - -@pytest.mark.asyncio -async def test_output_logging_passes_through_without_recipient(): - """Should pass through without registering handlers if no recipient.""" - middleware = OutputLoggingMiddleware() - activity = Activity( - type="message", - text="Hello", - from_property=ChannelAccount(name="Caller"), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", - ) - # Remove recipient so agent details cannot be derived - activity.recipient = None - adapter = MagicMock() - ctx = TurnContext(adapter, activity) - - logic_called = False - - async def logic(): - nonlocal logic_called - logic_called = True - - await middleware.on_turn(ctx, logic) - - assert logic_called is True - assert len(ctx._on_send_activities) == 0 - - -@pytest.mark.asyncio -async def test_output_logging_passes_through_without_tenant(): - """Should still register handlers even if no tenant id — tenant is optional.""" - middleware = OutputLoggingMiddleware() - ctx = _make_turn_context(recipient_tenant_id=None) - - logic_called = False - - async def logic(): - nonlocal logic_called - logic_called = True - - await middleware.on_turn(ctx, logic) - - assert logic_called is True - # Handlers should still be registered — tenant_id is optional now - assert len(ctx._on_send_activities) == 1 - - -@pytest.mark.asyncio -async def test_send_handler_skips_non_message_activities(): - """Send handler should skip non-message activities and call send_next.""" - middleware = OutputLoggingMiddleware() - ctx = _make_turn_context() - - await middleware.on_turn(ctx, AsyncMock()) - - # Get the registered handler - handler = ctx._on_send_activities[-1] - - # Create non-message activities - activities = [Activity(type="typing")] - send_next = AsyncMock() - - await handler(ctx, activities, send_next) - send_next.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_send_handler_creates_output_scope_for_messages(): - """Send handler should create an OutputScope for message activities and dispose on success.""" - middleware = OutputLoggingMiddleware() - ctx = _make_turn_context() - - await middleware.on_turn(ctx, AsyncMock()) - - handler = ctx._on_send_activities[-1] - - activities = [Activity(type="message", text="Reply message")] - send_next = AsyncMock() - - with patch( - "microsoft_agents_a365.observability.hosting.middleware" - ".output_logging_middleware.OutputScope" - ) as mock_output_scope_cls: - mock_scope = MagicMock() - mock_output_scope_cls.start.return_value = mock_scope - - await handler(ctx, activities, send_next) - - mock_output_scope_cls.start.assert_called_once() - send_next.assert_awaited_once() - mock_scope.dispose.assert_called_once() - mock_scope.record_error.assert_not_called() - - -@pytest.mark.asyncio -async def test_send_handler_uses_parent_span_from_turn_state(): - """Send handler should pass parent_context from turn_state to OutputScope.""" - middleware = OutputLoggingMiddleware() - ctx = _make_turn_context() - - traceparent = "00-1af7651916cd43dd8448eb211c80319c-c7ad6b7169203331-01" - ctx.turn_state[A365_PARENT_TRACEPARENT_KEY] = traceparent - - await middleware.on_turn(ctx, AsyncMock()) - - handler = ctx._on_send_activities[-1] - - activities = [Activity(type="message", text="Reply")] - send_next = AsyncMock() - - with patch( - "microsoft_agents_a365.observability.hosting.middleware" - ".output_logging_middleware.OutputScope" - ) as mock_output_scope_cls: - mock_scope = MagicMock() - mock_output_scope_cls.start.return_value = mock_scope - - await handler(ctx, activities, send_next) - - call_kwargs = mock_output_scope_cls.start.call_args - # span_details should be set with parent_context (extracted from traceparent header) - assert "span_details" in call_kwargs.kwargs - assert call_kwargs.kwargs["span_details"] is not None - assert call_kwargs.kwargs["span_details"].parent_context is not None - - -@pytest.mark.asyncio -async def test_send_handler_rethrows_errors(): - """Send handler should re-throw errors from send_next after recording them.""" - middleware = OutputLoggingMiddleware() - ctx = _make_turn_context() - - await middleware.on_turn(ctx, AsyncMock()) - - handler = ctx._on_send_activities[-1] - - activities = [Activity(type="message", text="Reply")] - send_error = RuntimeError("send pipeline failed") - send_next = AsyncMock(side_effect=send_error) - - with patch( - "microsoft_agents_a365.observability.hosting.middleware" - ".output_logging_middleware.OutputScope" - ) as mock_output_scope_cls: - mock_scope = MagicMock() - mock_output_scope_cls.start.return_value = mock_scope - - with pytest.raises(RuntimeError, match="send pipeline failed"): - await handler(ctx, activities, send_next) - - mock_scope.record_error.assert_called_once_with(send_error) - mock_scope.dispose.assert_called_once() diff --git a/tests/observability/hosting/scope_helpers/test_populate_baggage.py b/tests/observability/hosting/scope_helpers/test_populate_baggage.py deleted file mode 100644 index 58f9f80a..00000000 --- a/tests/observability/hosting/scope_helpers/test_populate_baggage.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from unittest.mock import MagicMock - -from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents_a365.observability.core.constants import USER_ID_KEY -from microsoft_agents_a365.observability.core.middleware.baggage_builder import BaggageBuilder -from microsoft_agents_a365.observability.hosting.scope_helpers.populate_baggage import populate - - -def test_populate(): - """Test populate populates BaggageBuilder from turn context.""" - # Create a real activity and turn context - activity = Activity( - type="message", - from_property=ChannelAccount( - aad_object_id="caller-id", - name="Caller", - agentic_user_id="caller-upn", - tenant_id="tenant-id", - ), - recipient=ChannelAccount(tenant_id="tenant-id", role="user"), - conversation=ConversationAccount(id="conv-id"), - service_url="https://example.com", - channel_id="test-channel", - ) - adapter = MagicMock() - turn_context = TurnContext(adapter, activity) - - builder = BaggageBuilder() - - result = populate(builder, turn_context) - - assert result == builder - # Verify builder was populated by checking its internal _pairs dict - assert len(builder._pairs) > 0 - # Verify specific expected baggage keys were set - assert USER_ID_KEY in builder._pairs - assert builder._pairs[USER_ID_KEY] == "caller-id" diff --git a/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py b/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py deleted file mode 100644 index 95b8e04a..00000000 --- a/tests/observability/hosting/scope_helpers/test_populate_invoke_agent_scope.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os -from unittest.mock import MagicMock - -import pytest -from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents_a365.observability.core.agent_details import AgentDetails -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_NAME_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_INPUT_MESSAGES_KEY, - USER_ID_KEY, -) -from microsoft_agents_a365.observability.core.invoke_agent_details import InvokeAgentScopeDetails -from microsoft_agents_a365.observability.core.invoke_agent_scope import InvokeAgentScope -from microsoft_agents_a365.observability.core.request import Request -from microsoft_agents_a365.observability.hosting.scope_helpers.populate_invoke_agent_scope import ( - populate, -) -from opentelemetry import trace -from opentelemetry.sdk.trace import TracerProvider - - -@pytest.fixture(autouse=True) -def enable_telemetry(): - """Enable telemetry and set up tracer provider for all tests in this module.""" - # Set environment variable to enable telemetry - os.environ["ENABLE_OBSERVABILITY"] = "true" - - # Set up a proper tracer provider - provider = TracerProvider() - trace.set_tracer_provider(provider) - - yield - - # Clean up - os.environ.pop("ENABLE_OBSERVABILITY", None) - - -def test_populate(): - """Test populate populates scope from turn context.""" - # Create real InvokeAgentScope with minimal required parameters - invoke_scope_details = InvokeAgentScopeDetails() - agent_details = AgentDetails(agent_id="test-agent", agent_name="Test Agent") - scope = InvokeAgentScope(Request(), invoke_scope_details, agent_details) - - # Create real Activity and TurnContext - activity = Activity( - type="message", - from_property=ChannelAccount(id="caller-id", aad_object_id="caller-aad-id", name="Caller"), - recipient=ChannelAccount(id="agent-id", agentic_app_id="agent-app-id", name="Agent"), - conversation=ConversationAccount(id="conv-123"), - text="Test message", - channel_id="test-channel", - service_url="https://example.com", - ) - adapter = MagicMock() - turn_context = TurnContext(adapter, activity) - - result = populate(scope, turn_context) - - # Verify function completes without error and returns the scope - assert result == scope - - # Verify attributes were set on the span - assert scope._span is not None - attributes = scope._span._attributes - - # Check caller attributes - assert USER_ID_KEY in attributes - assert attributes[USER_ID_KEY] == "caller-aad-id" - - # Check execution source - assert CHANNEL_NAME_KEY in attributes - assert attributes[CHANNEL_NAME_KEY] == "test-channel" - - # Check conversation ID - assert GEN_AI_CONVERSATION_ID_KEY in attributes - assert attributes[GEN_AI_CONVERSATION_ID_KEY] == "conv-123" - - # Check input messages - assert GEN_AI_INPUT_MESSAGES_KEY in attributes - assert "Test message" in attributes[GEN_AI_INPUT_MESSAGES_KEY] diff --git a/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py b/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py deleted file mode 100644 index 60e4327f..00000000 --- a/tests/observability/hosting/scope_helpers/test_scope_helper_utils.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount -from microsoft_agents_a365.observability.core.constants import ( - CHANNEL_LINK_KEY, - CHANNEL_NAME_KEY, - GEN_AI_AGENT_AUID_KEY, - GEN_AI_AGENT_DESCRIPTION_KEY, - GEN_AI_AGENT_EMAIL_KEY, - GEN_AI_AGENT_ID_KEY, - GEN_AI_AGENT_NAME_KEY, - GEN_AI_CONVERSATION_ID_KEY, - GEN_AI_CONVERSATION_ITEM_LINK_KEY, - TENANT_ID_KEY, - USER_EMAIL_KEY, - USER_ID_KEY, - USER_NAME_KEY, -) -from microsoft_agents_a365.observability.hosting.scope_helpers.utils import ( - get_caller_pairs, - get_channel_pairs, - get_conversation_pairs, - get_target_agent_pairs, - get_tenant_id_pair, -) - - -def test_get_caller_pairs(): - """Test get_caller_pairs extracts caller information from activity.""" - from_account = ChannelAccount( - aad_object_id="caller-aad-id", - name="Test Caller", - agentic_user_id="caller-upn", - tenant_id="caller-tenant-id", - ) - activity = Activity(type="message", from_property=from_account) - - result = list(get_caller_pairs(activity)) - - assert (USER_ID_KEY, "caller-aad-id") in result - assert (USER_NAME_KEY, "Test Caller") in result - assert (USER_EMAIL_KEY, "caller-upn") in result - - -def test_get_target_agent_pairs(): - """Test get_target_agent_pairs extracts target agent information.""" - recipient = ChannelAccount( - agentic_app_id="agent-app-id", - name="Test Agent", - aad_object_id="agent-auid", - agentic_user_id="agent-upn", - role="agenticAppInstance", - ) - activity = Activity(type="message", recipient=recipient) - - result = list(get_target_agent_pairs(activity)) - - assert (GEN_AI_AGENT_ID_KEY, "agent-app-id") in result - assert (GEN_AI_AGENT_NAME_KEY, "Test Agent") in result - assert (GEN_AI_AGENT_AUID_KEY, "agent-auid") in result - assert (GEN_AI_AGENT_EMAIL_KEY, "agent-upn") in result - assert (GEN_AI_AGENT_DESCRIPTION_KEY, "agenticAppInstance") in result - - -def test_get_tenant_id_pair(): - """Test get_tenant_id_pair extracts tenant ID from recipient.""" - recipient = ChannelAccount(tenant_id="test-tenant-id") - activity = Activity(type="message", recipient=recipient) - - result = list(get_tenant_id_pair(activity)) - - assert (TENANT_ID_KEY, "test-tenant-id") in result - - -def test_get_channel_pairs(): - """Test get_channel_pairs extracts channel metadata.""" - activity = Activity(type="message", channel_id="test-channel") - - result = list(get_channel_pairs(activity)) - - assert (CHANNEL_NAME_KEY, "test-channel") in result - assert (CHANNEL_LINK_KEY, None) in result - - -def test_get_conversation_pairs(): - """Test get_conversation_pairs extracts conversation information.""" - conversation = ConversationAccount(id="conversation-123") - activity = Activity( - type="message", conversation=conversation, service_url="https://example.com" - ) - - result = list(get_conversation_pairs(activity)) - - assert (GEN_AI_CONVERSATION_ID_KEY, "conversation-123") in result - assert (GEN_AI_CONVERSATION_ITEM_LINK_KEY, "https://example.com") in result diff --git a/tests/observability/hosting/token_cache_helpers/__init__.py b/tests/observability/hosting/token_cache_helpers/__init__.py deleted file mode 100644 index 59e481eb..00000000 --- a/tests/observability/hosting/token_cache_helpers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. diff --git a/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py b/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py deleted file mode 100644 index 6ca2b6fd..00000000 --- a/tests/observability/hosting/token_cache_helpers/test_agent_token_cache.py +++ /dev/null @@ -1,441 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -"""Tests for AgenticTokenCache app-only OBS token handling.""" - -import asyncio -import base64 -import json -import time -from unittest.mock import AsyncMock, MagicMock - -import pytest -from microsoft_agents.hosting.core.app.oauth.authorization import Authorization -from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents_a365.observability.hosting.token_cache_helpers import ( - AgenticTokenCache, - AgenticTokenStruct, -) - - -class StatusError(Exception): - """Exception with an HTTP-like status code.""" - - def __init__(self, status: int) -> None: - super().__init__(f"status {status}") - self.status = status - - -def _encode_segment(value: dict[str, object]) -> str: - encoded = base64.urlsafe_b64encode(json.dumps(value).encode("utf-8")).decode("utf-8") - return encoded.rstrip("=") - - -def make_jwt(exp_seconds_from_now: int, claims: dict[str, object] | None = None) -> str: - """Create an unsigned JWT-like token for cache expiry tests.""" - payload = { - "exp": int(time.time()) + exp_seconds_from_now, - "idtyp": "app", - } - if claims is not None: - payload.update(claims) - return f"{_encode_segment({'alg': 'none'})}.{_encode_segment(payload)}.sig" - - -@pytest.fixture -def mock_authorization(): - """Create a mock Authorization instance.""" - auth = MagicMock(spec=Authorization) - auth.exchange_token = AsyncMock() - return auth - - -@pytest.fixture -def mock_turn_context(): - """Create a mock TurnContext instance.""" - return MagicMock(spec=TurnContext) - - -@pytest.fixture -def token_cache(): - """Create a fresh AgenticTokenCache instance.""" - return AgenticTokenCache() - - -@pytest.mark.asyncio -async def test_get_observability_token_returns_none_without_entry(token_cache): - """A cache miss returns None.""" - assert await token_cache.get_observability_token("agent", "tenant") is None - - -@pytest.mark.asyncio -async def test_refresh_passes_identity_and_default_app_only_scope(token_cache): - """Refresh calls the resolver with exporting identity and OBS /.default scope.""" - token = make_jwt(300, {"roles": []}) - calls = [] - - def resolver(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - calls.append((agent_id, tenant_id, scopes)) - return token - - refreshed = await token_cache.refresh_observability_token("agent", "tenant", resolver) - - assert refreshed == token - assert await token_cache.get_observability_token("agent", "tenant") == token - assert calls == [("agent", "tenant", ["api://9b975845-388f-4429-889e-eab1ef63949c/.default"])] - - -@pytest.mark.asyncio -async def test_refresh_uses_custom_app_only_scopes(): - """Constructor-provided scopes are passed through to the resolver.""" - token_cache = AgenticTokenCache(observability_scopes=["api://custom-obs/.default"]) - token = make_jwt(300) - resolver = MagicMock(return_value=token) - - await token_cache.refresh_observability_token("agent", "tenant", resolver) - - resolver.assert_called_once_with("agent", "tenant", ["api://custom-obs/.default"]) - - -@pytest.mark.asyncio -async def test_single_scope_string_is_not_split_into_characters(): - """A bare scope string is treated as one scope.""" - token_cache = AgenticTokenCache(observability_scopes="api://custom-obs/.default") - resolver = MagicMock(return_value=make_jwt(300)) - - await token_cache.refresh_observability_token("agent", "tenant", resolver) - - resolver.assert_called_once_with("agent", "tenant", ["api://custom-obs/.default"]) - - -@pytest.mark.asyncio -async def test_refresh_accepts_async_resolver(token_cache): - """Async app-only resolvers are awaited by the hosting cache.""" - token = make_jwt(300) - - async def resolver(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - await asyncio.sleep(0) - return f"{agent_id}:{tenant_id}:{scopes[0]}:{token}" - - refreshed = await token_cache.refresh_observability_token("agent", "tenant", resolver) - - assert refreshed == ( - f"agent:tenant:api://9b975845-388f-4429-889e-eab1ef63949c/.default:{token}" - ) - - -@pytest.mark.asyncio -async def test_legacy_register_shape_logs_once_and_does_not_exchange( - token_cache, mock_authorization, mock_turn_context, caplog -): - """Removed delegated registration shape is a safe no-op.""" - token_struct = AgenticTokenStruct( - authorization=mock_authorization, - turn_context=mock_turn_context, - ) - - with caplog.at_level("ERROR"): - token_cache.register_observability("agent", "tenant", token_struct, ["scope"]) - token_cache.register_observability("agent", "tenant", token_struct, ["scope"]) - - mock_authorization.exchange_token.assert_not_called() - assert await token_cache.get_observability_token("agent", "tenant") is None - assert sum("Delegated OBS token" in record.message for record in caplog.records) == 1 - - -@pytest.mark.asyncio -async def test_refresh_rejects_non_callable_resolver(token_cache): - """refresh_observability_token requires a callable resolver.""" - with pytest.raises(TypeError, match="token_resolver must be callable"): - await token_cache.refresh_observability_token("agent", "tenant", "not-callable") - - -@pytest.mark.asyncio -@pytest.mark.parametrize("agent_id,tenant_id", [("", "tenant"), ("agent", " ")]) -async def test_refresh_rejects_empty_identity(token_cache, agent_id, tenant_id): - """Agent and tenant IDs are required for app-only refresh.""" - resolver = MagicMock(return_value=make_jwt(300)) - - with pytest.raises(ValueError, match="Agent and tenant IDs"): - await token_cache.refresh_observability_token(agent_id, tenant_id, resolver) - - resolver.assert_not_called() - - -@pytest.mark.asyncio -async def test_refresh_rejects_empty_scope_configuration(): - """An empty OBS scope configuration fails before token acquisition.""" - token_cache = AgenticTokenCache(observability_scopes=[]) - resolver = MagicMock(return_value=make_jwt(300)) - - with pytest.raises(ValueError, match="No valid scopes"): - await token_cache.refresh_observability_token("agent", "tenant", resolver) - - resolver.assert_not_called() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("token", [None, "", " "]) -async def test_refresh_surfaces_empty_resolver_result(token_cache, token): - """Empty resolver results propagate as refresh failures.""" - resolver = MagicMock(return_value=token) - - with pytest.raises(RuntimeError, match="returned no token"): - await token_cache.refresh_observability_token("agent", "tenant", resolver) - - assert await token_cache.get_observability_token("agent", "tenant") is None - - -@pytest.mark.asyncio -async def test_refresh_propagates_permanent_resolver_failure(token_cache): - """Permanent acquisition errors clear stale cached tokens and propagate.""" - await token_cache.refresh_observability_token("agent", "tenant", lambda *_: make_jwt(300)) - error = RuntimeError("permission denied") - resolver = MagicMock(side_effect=error) - token_cache.invalidate_token("agent", "tenant") - - with pytest.raises(RuntimeError, match="permission denied"): - await token_cache.refresh_observability_token("agent", "tenant", resolver) - - assert await token_cache.get_observability_token("agent", "tenant") is None - - -@pytest.mark.asyncio -async def test_refresh_retries_transient_failure_then_caches(token_cache, monkeypatch): - """Transient resolver failures are retried before succeeding.""" - token = make_jwt(300) - resolver = MagicMock(side_effect=[StatusError(503), token]) - - async def no_sleep(delay: float) -> None: - return None - - monkeypatch.setattr(asyncio, "sleep", no_sleep) - - refreshed = await token_cache.refresh_observability_token("agent", "tenant", resolver) - - assert refreshed == token - assert resolver.call_count == 2 - assert await token_cache.get_observability_token("agent", "tenant") == token - - -@pytest.mark.asyncio -@pytest.mark.parametrize("error", [TimeoutError(), ConnectionResetError()]) -async def test_refresh_retries_standard_transient_exceptions(token_cache, monkeypatch, error): - """Bare timeout and connection-reset errors are retried even without a message.""" - token = make_jwt(300) - resolver = MagicMock(side_effect=[error, token]) - - async def no_sleep(delay: float) -> None: - return None - - monkeypatch.setattr(asyncio, "sleep", no_sleep) - - assert await token_cache.refresh_observability_token("agent", "tenant", resolver) == token - assert resolver.call_count == 2 - - -@pytest.mark.asyncio -async def test_refresh_deduplicates_concurrent_same_identity_acquisition(token_cache): - """Concurrent refreshes for the same key share the first acquired token.""" - token = make_jwt(300) - call_count = 0 - - async def resolver(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - nonlocal call_count - call_count += 1 - await asyncio.sleep(0) - return token - - results = await asyncio.gather( - *(token_cache.refresh_observability_token("agent", "tenant", resolver) for _ in range(8)) - ) - - assert results == [token] * 8 - assert call_count == 1 - - -@pytest.mark.asyncio -async def test_refresh_reuses_cached_token_until_expiry_skew(token_cache): - """A non-expired token is reused; near-expiry tokens are not returned.""" - token = make_jwt(120) - resolver = MagicMock(return_value=token) - - await token_cache.refresh_observability_token("agent", "tenant", resolver) - await token_cache.refresh_observability_token("agent", "tenant", resolver) - - assert resolver.call_count == 1 - assert await token_cache.get_observability_token("agent", "tenant") == token - - token_cache.invalidate_token("agent", "tenant") - near_expiry = make_jwt(30) - await token_cache.refresh_observability_token("agent", "tenant", lambda *_: near_expiry) - assert await token_cache.get_observability_token("agent", "tenant") is None - - -@pytest.mark.asyncio -async def test_opaque_token_uses_fresh_fallback_ttl(token_cache): - """Opaque tokens get a fallback TTL after replacing expired JWT metadata.""" - await token_cache.refresh_observability_token("agent", "tenant", lambda *_: make_jwt(120)) - token_cache.invalidate_token("agent", "tenant") - await token_cache.refresh_observability_token("agent", "tenant", lambda *_: "opaque-token") - - assert await token_cache.get_observability_token("agent", "tenant") == "opaque-token" - entry = token_cache._map[AgenticTokenCache._make_key("agent", "tenant")] - entry.acquired_on_ms = (time.time() * 1000) - token_cache._default_max_token_age_ms - 1 - assert await token_cache.get_observability_token("agent", "tenant") is None - - -@pytest.mark.asyncio -async def test_tokens_are_isolated_by_agent_and_tenant(token_cache): - """Agent and tenant are both part of the cache key.""" - - def resolver(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - return f"{agent_id}:{tenant_id}" - - await token_cache.refresh_observability_token("agent-one", "tenant-a", resolver) - await token_cache.refresh_observability_token("agent-two", "tenant-a", resolver) - await token_cache.refresh_observability_token("agent-one", "tenant-b", resolver) - - assert ( - await token_cache.get_observability_token("agent-one", "tenant-a") == "agent-one:tenant-a" - ) - assert ( - await token_cache.get_observability_token("agent-two", "tenant-a") == "agent-two:tenant-a" - ) - assert ( - await token_cache.get_observability_token("agent-one", "tenant-b") == "agent-one:tenant-b" - ) - - -@pytest.mark.asyncio -async def test_invalidate_one_then_all(token_cache): - """Token invalidation is scoped by key or all keys.""" - token = make_jwt(300) - await token_cache.refresh_observability_token("one", "tenant", lambda *_: token) - await token_cache.refresh_observability_token("two", "tenant", lambda *_: token) - - token_cache.invalidate_token("one", "tenant") - assert await token_cache.get_observability_token("one", "tenant") is None - assert await token_cache.get_observability_token("two", "tenant") == token - - token_cache.invalidate_all() - assert await token_cache.get_observability_token("two", "tenant") is None - - -@pytest.mark.asyncio -async def test_invalidate_token_is_not_undone_by_in_flight_refresh(token_cache): - """A refresh that completes after invalidate_token() does not repopulate the cache.""" - release = asyncio.Event() - - async def slow_resolver(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - await release.wait() - return "token-acquired-before-invalidation" - - in_flight = asyncio.create_task( - token_cache.refresh_observability_token("agent", "tenant", slow_resolver) - ) - await asyncio.sleep(0) - token_cache.invalidate_token("agent", "tenant") - release.set() - - assert await in_flight == "token-acquired-before-invalidation" - assert await token_cache.get_observability_token("agent", "tenant") is None - - fresh = MagicMock(return_value="token-acquired-after-invalidation") - refreshed = await token_cache.refresh_observability_token("agent", "tenant", fresh) - assert refreshed == "token-acquired-after-invalidation" - fresh.assert_called_once() - - -@pytest.mark.asyncio -async def test_refresh_returns_the_cached_token_it_checked(token_cache): - """A concurrent invalidation between the usability check and the return never yields None.""" - token = make_jwt(300) - await token_cache.refresh_observability_token("agent", "tenant", lambda *_: token) - check_expiry = token_cache._is_expired - - def invalidate_during_check(entry: AgenticTokenCache._Entry) -> bool: - expired = check_expiry(entry) - token_cache.invalidate_token("agent", "tenant") - return expired - - token_cache._is_expired = invalidate_during_check - resolver = MagicMock(return_value="unused") - - assert await token_cache.refresh_observability_token("agent", "tenant", resolver) == token - resolver.assert_not_called() - - -@pytest.mark.asyncio -async def test_cache_evicts_oldest_entry_when_capacity_is_reached(token_cache): - """Cache size is bounded and evicts the oldest key.""" - token_cache._max_cache_size = 2 - token = make_jwt(300) - await token_cache.refresh_observability_token("one", "tenant", lambda *_: token) - await token_cache.refresh_observability_token("two", "tenant", lambda *_: token) - await token_cache.refresh_observability_token("three", "tenant", lambda *_: token) - - assert await token_cache.get_observability_token("one", "tenant") is None - assert await token_cache.get_observability_token("two", "tenant") == token - assert await token_cache.get_observability_token("three", "tenant") == token - - -@pytest.mark.asyncio -async def test_separator_bearing_ids_do_not_share_a_cache_entry(token_cache): - """IDs that contain a separator character never alias another identity.""" - first = MagicMock(return_value="token-for-first") - second = MagicMock(return_value="token-for-second") - - assert await token_cache.refresh_observability_token("a:b", "c", first) == "token-for-first" - assert await token_cache.refresh_observability_token("a", "b:c", second) == "token-for-second" - - second.assert_called_once() - assert await token_cache.get_observability_token("a:b", "c") == "token-for-first" - assert await token_cache.get_observability_token("a", "b:c") == "token-for-second" - - -@pytest.mark.asyncio -async def test_identity_churn_keeps_every_per_identity_registry_bounded(token_cache): - """Refresh locks live and die with cache entries, so identity churn stays bounded.""" - token_cache._max_cache_size = 2 - token = make_jwt(300) - for agent_id in ("one", "two", "three", "four"): - await token_cache.refresh_observability_token(agent_id, "tenant", lambda *_: token) - - registries = { - name: len(value) for name, value in vars(token_cache).items() if isinstance(value, dict) - } - assert registries == {"_map": 2} - - token_cache.invalidate_all() - registries = { - name: len(value) for name, value in vars(token_cache).items() if isinstance(value, dict) - } - assert registries == {"_map": 0} - - -@pytest.mark.asyncio -async def test_eviction_keeps_in_flight_entry_then_trims_overflow(token_cache): - """Eviction skips a refresh that is still running; the next insertion restores the bound.""" - token_cache._max_cache_size = 1 - release = asyncio.Event() - - async def slow_resolver(agent_id: str, tenant_id: str, scopes: list[str]) -> str: - await release.wait() - return "token-for-slow" - - in_flight = asyncio.create_task( - token_cache.refresh_observability_token("slow", "tenant", slow_resolver) - ) - await asyncio.sleep(0) - fast = await token_cache.refresh_observability_token( - "fast", "tenant", lambda *_: "token-for-fast" - ) - - release.set() - assert fast == "token-for-fast" - assert await in_flight == "token-for-slow" - assert await token_cache.get_observability_token("slow", "tenant") == "token-for-slow" - - await token_cache.refresh_observability_token("next", "tenant", lambda *_: "token-for-next") - assert len(token_cache._map) == 1 diff --git a/tests/runtime/test_environment_utils.py b/tests/runtime/test_environment_utils.py index 4fdde30f..5a400fdc 100644 --- a/tests/runtime/test_environment_utils.py +++ b/tests/runtime/test_environment_utils.py @@ -5,53 +5,10 @@ import pytest from microsoft_agents_a365.runtime.environment_utils import ( - PROD_OBSERVABILITY_SCOPE, - get_observability_authentication_scope, is_development_environment, ) -def test_get_observability_authentication_scope(): - """Test get_observability_authentication_scope returns app-only production scope.""" - result = get_observability_authentication_scope() - assert result == [PROD_OBSERVABILITY_SCOPE] - assert result == ["api://9b975845-388f-4429-889e-eab1ef63949c/.default"] - - -def test_get_observability_authentication_scope_with_override(monkeypatch): - """Test get_observability_authentication_scope returns override when env var is set.""" - override_scope = "https://override.example.com/.default" - monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", override_scope) - - result = get_observability_authentication_scope() - assert result == [override_scope] - - -def test_get_observability_authentication_scope_ignores_empty_override(monkeypatch): - """Test get_observability_authentication_scope ignores empty string override.""" - monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", "") - - result = get_observability_authentication_scope() - assert result == [PROD_OBSERVABILITY_SCOPE] - - -def test_get_observability_authentication_scope_ignores_whitespace_override(monkeypatch): - """Test get_observability_authentication_scope ignores whitespace-only override.""" - monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", " ") - - result = get_observability_authentication_scope() - assert result == [PROD_OBSERVABILITY_SCOPE] - - -def test_get_observability_authentication_scope_trims_whitespace(monkeypatch): - """Test get_observability_authentication_scope trims whitespace from override.""" - override_scope = " https://override.example.com/.default " - monkeypatch.setenv("A365_OBSERVABILITY_SCOPE_OVERRIDE", override_scope) - - result = get_observability_authentication_scope() - assert result == [override_scope.strip()] - - @pytest.mark.parametrize( "env_value,expected", [ diff --git a/tests/test_dependency_constraints.py b/tests/test_dependency_constraints.py index af88b985..184e47b6 100644 --- a/tests/test_dependency_constraints.py +++ b/tests/test_dependency_constraints.py @@ -25,10 +25,7 @@ "microsoft-agents-a365-tooling-extensions-agentframework", "microsoft-agents-a365-tooling-extensions-semantickernel", "microsoft-agents-a365-tooling-extensions-azureaifoundry", - "microsoft-agents-a365-observability-core", - "microsoft-agents-a365-observability-extensions-openai", - "microsoft-agents-a365-observability-extensions-agent-framework", - "microsoft-agents-a365-observability-extensions-semantickernel", + "microsoft-agents-a365-tooling-extensions-googleadk", "microsoft-agents-a365-runtime", "microsoft-agents-a365-notifications", } diff --git a/tests/test_published_security_constraints.py b/tests/test_published_security_constraints.py index 9dd75c8c..f13efde9 100644 --- a/tests/test_published_security_constraints.py +++ b/tests/test_published_security_constraints.py @@ -22,8 +22,6 @@ PACKAGE_PREFIX = "microsoft-agents-a365-" SECURITY_FLOORS = { "anyio": "4.14.2", - "langchain-core": "1.3.3", - "langsmith": "0.8.18", "mcp": "1.28.1", "pyasn1": "0.6.4", "pyjwt": "2.14.0", @@ -32,12 +30,6 @@ MCP_REQUIREMENTS = {"mcp", "anyio", "python-multipart", "pyjwt"} EXPECTED_REQUIREMENTS = { "notifications": {"pyjwt"}, - "observability-core": {"pyjwt"}, - "observability-extensions-agent-framework": {"pyjwt"}, - "observability-extensions-langchain": {"langchain-core", "langsmith", "anyio", "pyjwt"}, - "observability-extensions-openai": MCP_REQUIREMENTS, - "observability-extensions-semantic-kernel": MCP_REQUIREMENTS, - "observability-hosting": {"pyjwt"}, "runtime": {"pyjwt"}, "tooling": {"pyjwt"}, "tooling-extensions-agentframework": MCP_REQUIREMENTS | {"pyasn1"}, @@ -114,6 +106,30 @@ def published_requirements( return requirements +def test_no_retired_distribution_dependencies( + published_requirements: dict[str, list[Requirement]], +) -> None: + for package, requirements in published_requirements.items(): + assert not package.startswith(PACKAGE_PREFIX + "observability") + for requirement in requirements: + assert not canonicalize_name(requirement.name).startswith( + PACKAGE_PREFIX + "observability" + ), f"{package} still depends on {requirement}" + + +def test_tooling_publishes_required_runtime_dependency( + published_requirements: dict[str, list[Requirement]], +) -> None: + requirements = [ + requirement + for requirement in published_requirements[PACKAGE_PREFIX + "tooling"] + if canonicalize_name(requirement.name) == PACKAGE_PREFIX + "runtime" + ] + assert len(requirements) == 1, "Tooling must install Runtime without observability packages" + assert requirements[0].marker is None + assert str(requirements[0].specifier) == "==0.0.0" + + def _runtime_requirements( name: str, published: dict[str, list[Requirement]] ) -> Iterator[Requirement]: diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 00000000..679964b7 --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Release gates must reject stale or retired artifacts, not just skip uploading them.""" + +import subprocess +import sys +import tomllib +from pathlib import Path +from zipfile import ZipFile + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def test_workspace_has_no_retired_packages() -> None: + with (ROOT / "pyproject.toml").open("rb") as file: + workspace = tomllib.load(file)["tool"]["uv"] + assert not any("observability" in member for member in workspace["workspace"]["members"]) + assert not any("observability" in name for name in workspace["sources"]) + + +def _wheel(directory: Path, name: str, version: str = "1.2.3", dependency: str = "") -> Path: + normalized = name.replace("-", "_") + path = directory / f"{normalized}-{version}-py3-none-any.whl" + metadata = f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + if dependency: + metadata += f"Requires-Dist: {dependency}\n" + with ZipFile(path, "w") as archive: + archive.writestr(f"{normalized}-{version}.dist-info/METADATA", metadata + "\n") + return path + + +@pytest.fixture +def release_dir(tmp_path: Path) -> Path: + for manifest in (ROOT / "libraries").glob("*/pyproject.toml"): + with manifest.open("rb") as file: + name = tomllib.load(file)["project"]["name"] + if "observability" not in name: + _wheel(tmp_path, name) + return tmp_path + + +@pytest.mark.parametrize( + ("problem", "expected_error"), + [ + ("none", None), + ("sentinel", None), + ("retired", "Unexpected or duplicate distribution"), + ("dependency", "depends on retired distribution"), + ("stale-version", "Stale version in"), + ("missing", "Missing distributions"), + ("extra-file", "Unexpected release artifact"), + ("payload", "Retired observability payload"), + ("duplicate", "Unexpected or duplicate distribution"), + ("filename-name", "Filename and metadata disagree"), + ("filename-version", "Filename and metadata disagree"), + ("corrupt-zip", "File is not a zip file"), + ], +) +def test_release_gate(release_dir: Path, problem: str, expected_error: str | None) -> None: + runtime = "microsoft-agents-a365-runtime" + if problem == "retired": + _wheel(release_dir, "microsoft-agents-a365-observability-core") + elif problem == "dependency": + _wheel( + release_dir, + runtime, + dependency='microsoft_agents_a365_observability_core; extra == "legacy"', + ) + elif problem == "stale-version": + next(release_dir.glob("microsoft_agents_a365_runtime-*.whl")).unlink() + _wheel(release_dir, runtime, version="1.2.2") + elif problem == "missing": + next(release_dir.glob("*.whl")).unlink() + elif problem == "extra-file": + (release_dir / "old.tar.gz").write_bytes(b"stale sdist") + elif problem == "payload": + with ZipFile(next(release_dir.glob("*.whl")), "a") as archive: + archive.writestr("microsoft_agents_a365/observability/core/__init__.py", "") + elif problem == "duplicate": + path = _wheel(release_dir, runtime) + path.with_name(path.name.replace("-py3-", "-py2-")).write_bytes(path.read_bytes()) + elif problem == "filename-name": + _wheel(release_dir, runtime).rename(release_dir / "not_runtime-1.2.3-py3-none-any.whl") + elif problem == "filename-version": + path = _wheel(release_dir, runtime) + path.rename(path.with_name(path.name.replace("1.2.3", "1.2.4"))) + elif problem == "corrupt-zip": + _wheel(release_dir, runtime).write_bytes(b"not a zip archive") + elif problem == "sentinel": + (release_dir / ".gitignore").write_text("*\n") + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "verify_release_artifacts.py"), + str(release_dir), + "--version", + "1.2.3", + ], + capture_output=True, + text=True, + check=False, + ) + if expected_error is None: + assert result.returncode == 0, result.stdout + result.stderr + else: + assert result.returncode != 0 + assert "Release artifact verification failed" in result.stderr + assert expected_error in result.stderr + if problem == "stale-version": + assert "expected 1.2.3" in result.stderr diff --git a/tests/usage_example.py b/tests/usage_example.py deleted file mode 100644 index a0285944..00000000 --- a/tests/usage_example.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import os - - -def main(): - """Demonstrate the aligned Microsoft Agent 365 Python SDK functionality.""" - - # Enable telemetry (aligned with .NET SDK environment variable) - os.environ["ENABLE_OBSERVABILITY"] = "true" - - # Import the updated SDK classes - from microsoft_agents_a365.observability.core import ( - AgentDetails, - Channel, - InvokeAgentScope, - InvokeAgentScopeDetails, - Request, - ServiceEndpoint, - configure, - ) - - print("🚀 Microsoft Agent 365 Python SDK - Aligned with .NET SDK") - print("=" * 50) - - # Configure telemetry (existing function still works) - configure("my-service", "my-namespace") - print("✅ Telemetry configured") - - # Example 1: Enhanced Agent Execution with Session and Channel - print("\n📋 Example 1: Enhanced Agent Execution") - - # Create channel (from calling agent) - channel = Channel( - name="Calling Agent", - link="The agent that initiated this request", - ) - - # Create a rich request object - Request( - content="Process customer inquiry about order status", - session_id="session-abc123", - channel=channel, - conversation_id="conv-12345", - ) - - # Note: ExecuteAgentScope has been removed from the SDK - # Tool execution can still be used directly - print(" 🔄 Tool execution example (ExecuteAgentScope no longer available)") - - # Example tool usage that would typically be inside an agent execution context - # Note: This would require proper agent_details in real usage - print(" 🔧 Tool execution would be used within agent contexts") - print(" ✅ SDK functionality demonstrated (ExecuteAgentScope removed)") - - # Example 2: Agent-to-Agent Invocation with Enhanced Details - print("\n📞 Example 2: Agent-to-Agent Invocation") - - # Create detailed agent information (aligned with .NET SDK AgentDetails) - target_agent_details = AgentDetails( - agent_id="inventory-agent-999", - agent_name="Inventory Agent", - agent_description="Handles inventory queries and updates", - icon_uri="https://example.com/inventory-agent-icon.png", - ) - - # Create invoke agent scope details (aligned with .NET SDK) - invoke_scope_details = InvokeAgentScopeDetails( - endpoint=ServiceEndpoint(hostname="agents.company.com", port=8080), - ) - - # Create request for the invocation - invoke_request = Request( - content="Check inventory for product SKU: ABC-123", - session_id="session-abc123", - channel=channel, - conversation_id="conv-xyz789", - ) - - # Use InvokeAgentScope with enhanced details (like .NET SDK) - with InvokeAgentScope.start(invoke_request, invoke_scope_details, target_agent_details): - print(" 📡 Agent invocation started with full agent details and session context") - print(f" 📊 Target: {target_agent_details.agent_name} ({target_agent_details.agent_id})") - print( - f" 🌐 Endpoint: " - f"{invoke_scope_details.endpoint.hostname}:{invoke_scope_details.endpoint.port}" - ) - print(f" 🎨 Icon: {target_agent_details.icon_uri}") - - print(" ✅ Agent invocation completed with comprehensive telemetry") - - # Example 3: Demonstrate Backward Compatibility - print("\n🔄 Example 3: Backward Compatibility") - - # Note: ExecuteAgentScope has been removed from the SDK - print(" ✅ ExecuteAgentScope has been removed from the SDK") - - # Tool execution still works but requires proper context in real usage - print(" ✅ Tool execution API available (requires agent context)") - - print("\n🎯 Key Alignments with .NET SDK:") - print(" ✅ AgentDetails now includes icon_uri") - print(" ✅ InvokeAgentScopeDetails for scope configuration") - print(" ✅ ExecuteAgentScope has been removed from Python SDK") - print(" ✅ Constants aligned: gen_ai.agent.id, session.id, gen_ai.agent365.icon_uri") - print(" ✅ New classes: Channel, Request, SpanDetails, UserDetails, CallerDetails") - print(" ✅ Baggage propagation from parent to child spans") - print(" ✅ Backward compatibility maintained") - - print("\n🎉 Python SDK is now fully aligned with .NET SDK!") - - -if __name__ == "__main__": - main() diff --git a/uv.lock b/uv.lock index cf97b195..f4d11198 100644 --- a/uv.lock +++ b/uv.lock @@ -11,12 +11,6 @@ resolution-markers = [ [manifest] members = [ "microsoft-agents-a365-notifications", - "microsoft-agents-a365-observability-core", - "microsoft-agents-a365-observability-extensions-agent-framework", - "microsoft-agents-a365-observability-extensions-langchain", - "microsoft-agents-a365-observability-extensions-openai", - "microsoft-agents-a365-observability-extensions-semantic-kernel", - "microsoft-agents-a365-observability-hosting", "microsoft-agents-a365-runtime", "microsoft-agents-a365-tooling", "microsoft-agents-a365-tooling-extensions-agentframework", @@ -34,13 +28,9 @@ constraints = [ { name = "azure-ai-agents", specifier = ">=1.0.0b251001" }, { name = "azure-ai-projects", specifier = ">=1.0.0b12,<2.0.0" }, { name = "azure-identity", specifier = ">=1.12.0" }, - { name = "azure-monitor-ingestion", specifier = ">=1.0.0" }, - { name = "azure-monitor-opentelemetry-exporter", specifier = ">=1.0.0b39" }, { name = "black", specifier = ">=23.0.0" }, { name = "google-adk", specifier = ">=1.28.1,<2.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "langchain-core", specifier = ">=1.3.3" }, - { name = "langsmith", specifier = ">=0.8.18" }, { name = "mcp", specifier = ">=1.28.1" }, { name = "microsoft-agents-activity", specifier = ">=0.4.0" }, { name = "microsoft-agents-hosting-core", specifier = ">=0.4.0" }, @@ -48,9 +38,6 @@ constraints = [ { name = "openai", specifier = ">=2.0.0" }, { name = "openai-agents", specifier = ">=0.2.6" }, { name = "opentelemetry-api", specifier = ">=1.36.0" }, - { name = "opentelemetry-exporter-otlp", specifier = ">=1.36.0" }, - { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.36.0" }, - { name = "opentelemetry-instrumentation", specifier = ">=0.47b0" }, { name = "opentelemetry-sdk", specifier = ">=1.36.0" }, { name = "pyasn1", specifier = ">=0.6.4" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -65,8 +52,8 @@ constraints = [ { name = "semantic-kernel", specifier = ">=1.39.3" }, { name = "tox", specifier = ">=4.23.0" }, { name = "tox-uv", specifier = ">=1.18.0" }, + { name = "twine", specifier = "==6.2.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, - { name = "wrapt", specifier = ">=1.14" }, ] overrides = [{ name = "azure-ai-projects", specifier = ">=1.0.0b12,<2.0.0" }] @@ -74,11 +61,11 @@ overrides = [{ name = "azure-ai-projects", specifier = ">=1.0.0b12,<2.0.0" }] dev = [ { name = "agent-framework" }, { name = "azure-identity" }, - { name = "langchain-openai" }, { name = "openai" }, { name = "openai-agents" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "python-dotenv" }, { name = "ruff" }, @@ -86,6 +73,7 @@ dev = [ { name = "tomlkit" }, { name = "tox", specifier = ">=4.0" }, { name = "tox-uv", specifier = ">=1.0" }, + { name = "twine" }, ] [[package]] @@ -998,37 +986,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-identity/1.25.1/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651" }, ] -[[package]] -name = "azure-monitor-ingestion" -version = "1.1.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-monitor-ingestion/1.1/azure_monitor_ingestion-1.1.0.tar.gz", hash = "sha256:97afee780d9ae3069128cde77027d4cc62fa824fe64d5bac880ac4a64b0a0c4e" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-monitor-ingestion/1.1/azure_monitor_ingestion-1.1.0-py3-none-any.whl", hash = "sha256:41765fb3df8e0933f1a7eaf1cb0a5f30acce856afa8831bfa4777ad26594f019" }, -] - -[[package]] -name = "azure-monitor-opentelemetry-exporter" -version = "1.0.0b45" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "azure-core" }, - { name = "azure-identity" }, - { name = "msrest" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "psutil" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-monitor-opentelemetry-exporter/1b45/azure_monitor_opentelemetry_exporter-1.0.0b45.tar.gz", hash = "sha256:b2d495b318710c521d6611cfb9cb4b36d990e3d613cf10ebdaa957099ddbe351" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/azure-monitor-opentelemetry-exporter/1b45/azure_monitor_opentelemetry_exporter-1.0.0b45-py2.py3-none-any.whl", hash = "sha256:10d6363ac971fb4530511df464898fe69c0499e85841febad800572d1e8e8ec6" }, -] - [[package]] name = "azure-storage-blob" version = "12.28.0" @@ -1053,6 +1010,15 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/backoff/2.2.1/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/backports-tarfile/1.2/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2026-08-04T05:16:30Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/backports-tarfile/1.2/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2026-07-24T16:37:59Z" }, +] + [[package]] name = "black" version = "26.10.1" @@ -1360,6 +1326,132 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/colorama/0.4.6/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] +[[package]] +name = "coverage" +version = "7.17.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-29T19:44:08Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:99a7b390cbccc6bf029ca4da3f0f607c218bb4085e9cd6b58a90d14ca12993c3", size = 225837, upload-time = "2026-10-29T16:07:42Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72640e02a470b57b15cd655820133f6892760209283a363a60e2df932d5f0fe", size = 226257, upload-time = "2026-10-29T17:30:35Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2dc9233f39149b5a4f4aba17a4b6bc5cca22e52cfc4b06fdb07d014439b8b2ea", size = 258282, upload-time = "2026-10-22T16:03:58Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e8113d3852e9377421a61de1917df27b3abb25404a4ecf69f9e64612c4345b5", size = 260506, upload-time = "2026-10-29T16:08:16Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86a39539d10d666bb819c16c94d7edfc1aa2e0531b937f7c549b83d15177c6e", size = 262600, upload-time = "2026-10-22T16:04:01Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:559f748446ee799803686b5784e3deb54c70d655ac386a860e06b11672504ba7", size = 264609, upload-time = "2026-10-22T16:04:03Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d65c9e8e257f48a654908005046fbf1bc48d9540ae3776ad08c0acd010e10c", size = 258382, upload-time = "2026-10-22T16:04:04Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b7fa0997b8eba7378ac66f31ec7f70164490353ab79b9667d37c0d365e50177", size = 260299, upload-time = "2026-10-22T16:04:06Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ae0453a3390e67f7bfbe34417bbdf5b4ebfc21db23fb446a0a41115eeeca6ca", size = 258314, upload-time = "2026-10-22T16:04:07Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:96151262832f3c230ebae23b2d6911ad549e1738465946c97004913ff06112ba", size = 262239, upload-time = "2026-10-22T16:04:09Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cd7397de6cc782f00fe5ac1d75e9b306bcfa49c8b6b8dd6ed1f7709393ad40b3", size = 257841, upload-time = "2026-10-22T16:04:10Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d1fdf82c6fea57b5e9dada509f9ce9638c8334664e2c98c7f6f1ccf3d143ad2", size = 259039, upload-time = "2026-10-30T07:47:23Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-win32.whl", hash = "sha256:38c86e5d74bbfd88a393fa3f91257c1225c63aa6db6d5918de3e49ab04f91c6e", size = 227687, upload-time = "2026-10-22T16:04:13Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ed3097c6817bc1a7fc8ae0a6131db74aa1150d863cccc308237ff362d3226b2", size = 228290, upload-time = "2026-10-29T16:37:34Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:6052e878b628abcd38fa5f2b64abebc686eed4e2267da42434017a9c6d24f241", size = 228199, upload-time = "2026-10-22T16:04:16Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0cfdc938ca3319d873d1f1252e60620a39aee4ebf9dbd7cc58238109ebda9c51", size = 225938, upload-time = "2026-10-29T16:04:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d523da3c2e4bca49417768bcdb1a443c5b359dc44b8b93a3102e118fce912c40", size = 226166, upload-time = "2026-10-29T17:01:50Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:adbf3450c0f3f2b2854e282a8758f2b3eddf787eeff24ec9c9bc2cbc38328cc1", size = 258462, upload-time = "2026-10-22T16:04:21Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85932227e9dd38e62cd29c17fefdfecfb919eead6af1e3259522d03cf85de983", size = 261789, upload-time = "2026-10-29T16:05:04Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f5477aac9c9e4a0a641c55f8f8fddd7f3bd91176c912c50f40ffced96b4034", size = 262322, upload-time = "2026-10-29T17:11:24Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b8bb651a4406fb6a983a905309fec1783443c65e1771b41e92740a0f4a1176b", size = 264600, upload-time = "2026-10-22T16:04:26Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0edeb6594f080c8de1263cccb239cb316ef77aeb959e2e0f38f2953fcc72a49e", size = 258678, upload-time = "2026-10-22T16:04:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da94ec0484e84fce5c7bf8d0f54b97767a90d992653a129b35634699d516e0a5", size = 260210, upload-time = "2026-10-22T16:04:29Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a25cc5e8c23a590815c6a7aa43624234d25cb23b21b484adb8a0daed59860db5", size = 258375, upload-time = "2026-10-22T16:04:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db645c81cf4d6bcb392ea1894aad65d55d1588a8472c834b25ca575da3029a56", size = 262422, upload-time = "2026-10-22T16:04:32Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6a80a11b90c4a03df1502304cf24ff953255b6b5d076ef79066ecf88baaff31f", size = 257875, upload-time = "2026-10-22T16:04:33Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4eaf11d6b0f2147f46cfbf06fe7edcca6f095d0120da00ddc2458995ee869fc", size = 260361, upload-time = "2026-10-22T16:04:35Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-win32.whl", hash = "sha256:912a29f338798541d795e2e25903c6fe7a99dee70a40fe4706a9a124649b523e", size = 227747, upload-time = "2026-10-22T16:04:37Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:88a1a9fb0075b83389dd9b403bb3fce976031b3c2aa0e05e3e57143cf639de69", size = 228325, upload-time = "2026-10-29T16:04:04Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:2c680e838ff9e68d491158625ca2c1ddb17d78d791bba96a31f8b839b4990438", size = 228131, upload-time = "2026-10-29T23:47:53Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2", size = 227080, upload-time = "2026-10-29T17:16:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5", size = 227152, upload-time = "2026-10-22T16:04:43Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398", size = 225397, upload-time = "2026-10-22T16:04:45Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb", size = 225705, upload-time = "2026-10-22T16:04:46Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429", size = 225828, upload-time = "2026-10-22T16:04:48Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094", size = 226088, upload-time = "2026-10-29T16:36:38Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5", size = 257257, upload-time = "2026-10-22T16:04:50Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669", size = 260165, upload-time = "2026-10-29T16:28:52Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd", size = 261098, upload-time = "2026-10-22T16:04:53Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722", size = 263164, upload-time = "2026-10-22T16:04:55Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7", size = 257287, upload-time = "2026-10-22T16:04:56Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398", size = 259256, upload-time = "2026-10-22T16:04:58Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e", size = 257178, upload-time = "2026-10-22T16:05:00Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28", size = 261275, upload-time = "2026-10-22T16:05:01Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95", size = 257029, upload-time = "2026-10-22T16:05:02Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9", size = 259109, upload-time = "2026-10-22T16:05:04Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d", size = 227691, upload-time = "2026-10-22T16:05:05Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d", size = 228418, upload-time = "2026-10-29T16:15:02Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83", size = 228142, upload-time = "2026-10-29T16:07:44Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-29T16:05:06Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-29T17:15:50Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-29T17:49:20Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-29T16:05:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559", size = 227196, upload-time = "2026-10-29T16:56:43Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b", size = 227315, upload-time = "2026-10-22T16:06:00Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c", size = 225490, upload-time = "2026-10-22T16:06:01Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f", size = 225797, upload-time = "2026-10-22T16:06:03Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109", size = 226025, upload-time = "2026-10-22T16:06:04Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4", size = 226193, upload-time = "2026-10-30T09:30:38Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968", size = 258046, upload-time = "2026-10-22T16:06:07Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c", size = 261735, upload-time = "2026-10-30T08:43:15Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771", size = 262819, upload-time = "2026-10-22T16:06:10Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f", size = 265194, upload-time = "2026-10-22T16:06:11Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28", size = 258410, upload-time = "2026-10-22T16:06:13Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d", size = 260697, upload-time = "2026-10-22T16:06:14Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b", size = 258649, upload-time = "2026-10-22T16:06:16Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592", size = 262989, upload-time = "2026-10-22T16:06:17Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549", size = 257955, upload-time = "2026-10-22T16:06:19Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089", size = 260512, upload-time = "2026-10-22T16:06:21Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67", size = 227913, upload-time = "2026-10-22T16:06:22Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e", size = 228532, upload-time = "2026-10-29T16:41:35Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036", size = 228393, upload-time = "2026-10-22T16:06:25Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a", size = 226481, upload-time = "2026-10-22T16:06:26Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6", size = 226876, upload-time = "2026-10-22T16:06:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569", size = 267241, upload-time = "2026-10-22T16:06:29Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca", size = 270079, upload-time = "2026-10-22T16:06:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36", size = 272775, upload-time = "2026-10-22T16:06:32Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697", size = 274110, upload-time = "2026-10-22T16:06:33Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2", size = 267775, upload-time = "2026-10-22T16:06:35Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350", size = 270268, upload-time = "2026-10-22T16:06:36Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a", size = 267298, upload-time = "2026-10-22T16:06:37Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3", size = 271899, upload-time = "2026-10-22T16:06:39Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03", size = 267380, upload-time = "2026-10-22T16:06:40Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35", size = 268993, upload-time = "2026-10-22T16:06:42Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802", size = 228216, upload-time = "2026-10-22T16:06:43Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc", size = 229146, upload-time = "2026-10-22T16:06:45Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c", size = 228716, upload-time = "2026-10-22T16:06:47Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/coverage/7.17/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-29T17:58:12Z" }, +] + [[package]] name = "cryptography" version = "50.0.0" @@ -1473,6 +1565,15 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/docstring-parser/0.17/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708" }, ] +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/docutils/0.23/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-09-04T21:38:10Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/docutils/0.23/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-07-28T11:01:42Z" }, +] + [[package]] name = "durabletask" version = "1.4.0" @@ -2228,47 +2329,52 @@ wheels = [ name = "greenlet" version = "3.3.1" source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451" }, +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-08-08T00:04:32Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-08-11T21:48:50Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-10-27T21:47:03Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-09-07T16:05:58Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-08-19T18:34:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-10-23T03:28:01Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-08-04T14:06:51Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-08-08T00:04:23Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-08-08T00:04:22Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-08-08T00:04:23Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-08-08T00:04:23Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-08-08T00:04:23Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-08-08T00:04:24Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-08-06T15:59:04Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-08-08T00:04:24Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-08-07T14:10:09Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-08-08T00:04:25Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-08-08T00:04:24Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-08-08T00:04:25Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-08-08T00:04:25Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-08-08T00:04:26Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-08-08T00:04:26Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-08-05T20:59:19Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-08-08T00:04:26Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-08-08T00:04:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-08-08T00:04:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-08-08T00:04:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-08-08T00:04:28Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-08-08T00:04:28Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-08-08T00:04:28Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-08-08T00:04:29Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-08-08T00:04:29Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-08-08T00:04:29Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-08-08T00:04:29Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-08-08T00:04:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-08-08T00:04:30Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-08-08T00:04:31Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-08-08T00:04:31Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-08-08T00:04:31Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-08-08T00:04:32Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/greenlet/3.3.1/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-08-08T00:04:32Z" }, ] [[package]] @@ -2535,6 +2641,18 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/hyperlight-sandbox-python-guest/0.5/hyperlight_sandbox_python_guest-0.5.0-py3-none-any.whl", hash = "sha256:7401c70aa8b2b8d3ce4dca729efe525e38e3b9ff5db3c991d52ba7bbdc398a66", size = 21768481, upload-time = "2026-07-24T15:25:05Z" }, ] +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/id/1.6.1/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-09-03T15:08:58Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/id/1.6.1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-07-24T13:37:51Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -2583,6 +2701,51 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/isodate/0.7.2/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jaraco-classes/3.4/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2026-08-03T16:03:40Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jaraco-classes/3.4/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2026-07-24T00:39:54Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jaraco-context/6.1.2/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-07-31T23:04:33Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jaraco-context/6.1.2/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-07-24T02:14:58Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jaraco-functools/4.6/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-08-28T23:56:40Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jaraco-functools/4.6/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-24T02:14:56Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jeepney/0.9/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2026-09-09T05:05:04Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jeepney/0.9/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2026-07-24T14:03:31Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -2701,18 +2864,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/joserfc/1.7.7/joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c", size = 71758, upload-time = "2026-10-23T04:43:22Z" }, ] -[[package]] -name = "jsonpatch" -version = "1.33" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "jsonpointer" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jsonpatch/1.33/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jsonpatch/1.33/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade" }, -] - [[package]] name = "jsonpath-ng" version = "1.8.0" @@ -2722,15 +2873,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jsonpath-ng/1.8/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138" }, ] -[[package]] -name = "jsonpointer" -version = "3.0.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jsonpointer/3/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/jsonpointer/3/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -2774,70 +2916,21 @@ wheels = [ ] [[package]] -name = "langchain-core" -version = "1.3.3" +name = "keyring" +version = "25.7.0" source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } dependencies = [ - { name = "jsonpatch" }, - { name = "langchain-protocol" }, - { name = "langsmith" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "uuid-utils" }, + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langchain-core/1.3.3/langchain_core-1.3.3.tar.gz", hash = "sha256:fa510a5db8efdc0c6ff41c0939fb5c00a0183c11f6b84233e892e3227ff69182" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/keyring/25.7/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2026-08-04T05:21:27Z" } wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langchain-core/1.3.3/langchain_core-1.3.3-py3-none-any.whl", hash = "sha256:18aae8506f37da7f74398492279a7d6efcee4f8e23c4c41c7af080eeb7ef7bd1" }, -] - -[[package]] -name = "langchain-openai" -version = "1.1.14" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "langchain-core" }, - { name = "openai" }, - { name = "tiktoken" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langchain-openai/1.1.14/langchain_openai-1.1.14.tar.gz", hash = "sha256:71b4262932fabe506ce79c175dbc956cc48f24d81e20b27662df493147750643", size = 1115195, upload-time = "2026-04-16T14:55:24Z" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langchain-openai/1.1.14/langchain_openai-1.1.14-py3-none-any.whl", hash = "sha256:cb525d2011f9813fc15a7dcfd4bca5b87badcbcb2c113a7fbe45d1b8a1bbb69c", size = 88705, upload-time = "2026-07-30T20:02:16Z" }, -] - -[[package]] -name = "langchain-protocol" -version = "0.0.19" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langchain-protocol/0.0.19/langchain_protocol-0.0.19.tar.gz", hash = "sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langchain-protocol/0.0.19/langchain_protocol-0.0.19-py3-none-any.whl", hash = "sha256:4cdf879a492a35980fd859ae792d3c65458ccaae504e183c9a10d7eac1f0720f" }, -] - -[[package]] -name = "langsmith" -version = "0.8.18" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "httpx" }, - { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "uuid-utils" }, - { name = "websockets" }, - { name = "xxhash" }, - { name = "zstandard" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langsmith/0.8.18/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/langsmith/0.8.18/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/keyring/25.7/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2026-07-24T07:34:45Z" }, ] [[package]] @@ -2954,6 +3047,18 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/mako/1.4.2/mako-1.4.2-py3-none-any.whl", hash = "sha256:e70a2a4308952b33a21208bfd6725da79fae5a8c224578f8cfdeb60e5ace03df", size = 80163, upload-time = "2026-09-29T20:41:02Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/markdown-it-py/4.2/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-08-03T20:50:23Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/markdown-it-py/4.2/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-07-24T00:51:56Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -3053,6 +3158,15 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/mcp/1.30/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-14T14:34:15Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/mdurl/0.1.2/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2026-07-31T21:51:37Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/mdurl/0.1.2/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2026-07-24T00:38:35Z" }, +] + [[package]] name = "mem0ai" version = "2.0.0" @@ -3112,261 +3226,6 @@ requires-dist = [ ] provides-extras = ["dev", "test"] -[[package]] -name = "microsoft-agents-a365-observability-core" -source = { editable = "libraries/microsoft-agents-a365-observability-core" } -dependencies = [ - { name = "microsoft-agents-a365-runtime" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-sdk" }, - { name = "pydantic" }, - { name = "typing-extensions" }, - { name = "wrapt" }, -] - -[package.optional-dependencies] -azure = [ - { name = "azure-identity" }, - { name = "azure-monitor-ingestion" }, - { name = "azure-monitor-opentelemetry-exporter" }, -] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] -jaeger = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, -] - -[package.metadata] -requires-dist = [ - { name = "azure-identity", marker = "extra == 'azure'" }, - { name = "azure-monitor-ingestion", marker = "extra == 'azure'" }, - { name = "azure-monitor-opentelemetry-exporter", marker = "extra == 'azure'" }, - { name = "black", marker = "extra == 'dev'" }, - { name = "microsoft-agents-a365-runtime", editable = "libraries/microsoft-agents-a365-runtime" }, - { name = "mypy", marker = "extra == 'dev'" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'jaeger'" }, - { name = "opentelemetry-sdk" }, - { name = "pydantic" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "pytest-asyncio", marker = "extra == 'dev'" }, - { name = "pytest-asyncio", marker = "extra == 'test'" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "typing-extensions" }, - { name = "wrapt" }, -] -provides-extras = ["azure", "jaeger", "dev", "test"] - -[[package]] -name = "microsoft-agents-a365-observability-extensions-agent-framework" -source = { editable = "libraries/microsoft-agents-a365-observability-extensions-agentframework" } -dependencies = [ - { name = "microsoft-agents-a365-observability-core" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, -] - -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'dev'" }, - { name = "microsoft-agents-a365-observability-core", editable = "libraries/microsoft-agents-a365-observability-core" }, - { name = "mypy", marker = "extra == 'dev'" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "pytest-asyncio", marker = "extra == 'dev'" }, - { name = "pytest-asyncio", marker = "extra == 'test'" }, - { name = "ruff", marker = "extra == 'dev'" }, -] -provides-extras = ["dev", "test"] - -[[package]] -name = "microsoft-agents-a365-observability-extensions-langchain" -source = { editable = "libraries/microsoft-agents-a365-observability-extensions-langchain" } -dependencies = [ - { name = "anyio" }, - { name = "langchain-core" }, - { name = "langsmith" }, - { name = "microsoft-agents-a365-observability-core" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "wrapt" }, -] - -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio" }, - { name = "black", marker = "extra == 'dev'" }, - { name = "langchain-core" }, - { name = "langsmith" }, - { name = "microsoft-agents-a365-observability-core", editable = "libraries/microsoft-agents-a365-observability-core" }, - { name = "mypy", marker = "extra == 'dev'" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "pytest-asyncio", marker = "extra == 'dev'" }, - { name = "pytest-asyncio", marker = "extra == 'test'" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "wrapt" }, -] -provides-extras = ["dev", "test"] - -[[package]] -name = "microsoft-agents-a365-observability-extensions-openai" -source = { editable = "libraries/microsoft-agents-a365-observability-extensions-openai" } -dependencies = [ - { name = "anyio" }, - { name = "mcp" }, - { name = "microsoft-agents-a365-observability-core" }, - { name = "openai-agents" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "python-multipart" }, -] - -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio" }, - { name = "black", marker = "extra == 'dev'" }, - { name = "mcp" }, - { name = "microsoft-agents-a365-observability-core", editable = "libraries/microsoft-agents-a365-observability-core" }, - { name = "mypy", marker = "extra == 'dev'" }, - { name = "openai-agents" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "pytest-asyncio", marker = "extra == 'dev'" }, - { name = "pytest-asyncio", marker = "extra == 'test'" }, - { name = "python-multipart" }, - { name = "ruff", marker = "extra == 'dev'" }, -] -provides-extras = ["dev", "test"] - -[[package]] -name = "microsoft-agents-a365-observability-extensions-semantic-kernel" -source = { editable = "libraries/microsoft-agents-a365-observability-extensions-semantickernel" } -dependencies = [ - { name = "anyio" }, - { name = "mcp" }, - { name = "microsoft-agents-a365-observability-core" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "python-multipart" }, - { name = "semantic-kernel" }, -] - -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] -test = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio" }, - { name = "black", marker = "extra == 'dev'" }, - { name = "mcp" }, - { name = "microsoft-agents-a365-observability-core", editable = "libraries/microsoft-agents-a365-observability-core" }, - { name = "mypy", marker = "extra == 'dev'" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'test'" }, - { name = "pytest-asyncio", marker = "extra == 'dev'" }, - { name = "pytest-asyncio", marker = "extra == 'test'" }, - { name = "python-multipart" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "semantic-kernel" }, -] -provides-extras = ["dev", "test"] - -[[package]] -name = "microsoft-agents-a365-observability-hosting" -source = { editable = "libraries/microsoft-agents-a365-observability-hosting" } -dependencies = [ - { name = "microsoft-agents-a365-observability-core" }, - { name = "microsoft-agents-a365-runtime" }, - { name = "microsoft-agents-hosting-core" }, - { name = "opentelemetry-api" }, -] - -[package.metadata] -requires-dist = [ - { name = "microsoft-agents-a365-observability-core", editable = "libraries/microsoft-agents-a365-observability-core" }, - { name = "microsoft-agents-a365-runtime", editable = "libraries/microsoft-agents-a365-runtime" }, - { name = "microsoft-agents-hosting-core" }, - { name = "opentelemetry-api" }, -] - [[package]] name = "microsoft-agents-a365-runtime" source = { editable = "libraries/microsoft-agents-a365-runtime" } @@ -3404,6 +3263,7 @@ provides-extras = ["dev", "test"] name = "microsoft-agents-a365-tooling" source = { editable = "libraries/microsoft-agents-a365-tooling" } dependencies = [ + { name = "microsoft-agents-a365-runtime" }, { name = "microsoft-agents-hosting-core" }, { name = "pydantic" }, { name = "pyjwt" }, @@ -3422,6 +3282,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'" }, + { name = "microsoft-agents-a365-runtime", editable = "libraries/microsoft-agents-a365-runtime" }, { name = "microsoft-agents-hosting-core" }, { name = "mypy", marker = "extra == 'dev'" }, { name = "pydantic" }, @@ -3905,22 +3766,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msal-extensions/1.3.1/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca" }, ] -[[package]] -name = "msrest" -version = "0.7.1" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "azure-core" }, - { name = "certifi" }, - { name = "isodate" }, - { name = "requests" }, - { name = "requests-oauthlib" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msrest/0.7.1/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/msrest/0.7.1/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32" }, -] - [[package]] name = "multidict" version = "6.9.1" @@ -4136,6 +3981,40 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nest-asyncio/1.6/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c" }, ] +[[package]] +name = "nh3" +version = "0.3.7" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7.tar.gz", hash = "sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848", size = 25662, upload-time = "2026-09-04T21:44:19Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba", size = 1471147, upload-time = "2026-08-31T02:15:11Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b", size = 820463, upload-time = "2026-09-04T21:43:44Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32", size = 861456, upload-time = "2026-09-04T21:43:47Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa", size = 1023930, upload-time = "2026-09-04T21:43:43Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac", size = 1102614, upload-time = "2026-09-04T21:43:52Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102", size = 1059915, upload-time = "2026-09-04T21:44:14Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a", size = 1047402, upload-time = "2026-09-04T21:44:07Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-win32.whl", hash = "sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946", size = 619895, upload-time = "2026-09-04T21:43:53Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d", size = 633456, upload-time = "2026-09-04T21:43:37Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp314-cp314t-win_arm64.whl", hash = "sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877", size = 611003, upload-time = "2026-09-04T21:43:56Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5", size = 1493959, upload-time = "2026-09-01T00:47:27Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479", size = 859615, upload-time = "2026-09-04T21:43:58Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506", size = 839872, upload-time = "2026-09-04T21:44:03Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086", size = 1091325, upload-time = "2026-09-04T21:43:46Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563", size = 1042482, upload-time = "2026-09-04T21:44:00Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174", size = 946868, upload-time = "2026-09-04T21:44:09Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42", size = 832161, upload-time = "2026-08-31T02:15:21Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8", size = 849791, upload-time = "2026-09-04T21:44:05Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493", size = 875473, upload-time = "2026-09-04T21:43:41Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd", size = 1036463, upload-time = "2026-09-04T21:44:02Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac", size = 1116029, upload-time = "2026-09-04T21:44:11Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62", size = 1076589, upload-time = "2026-09-04T21:43:39Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af", size = 1058871, upload-time = "2026-09-04T21:44:16Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-win32.whl", hash = "sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59", size = 630729, upload-time = "2026-09-04T21:43:55Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-win_amd64.whl", hash = "sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc", size = 644462, upload-time = "2026-08-31T11:51:25Z" }, + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/nh3/0.3.7/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-09-04T21:43:50Z" }, +] + [[package]] name = "numpy" version = "2.4.2" @@ -4215,15 +4094,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/numpy/2.4.2/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0" }, ] -[[package]] -name = "oauthlib" -version = "4.0.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/oauthlib/4/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-10-05T15:17:43Z" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/oauthlib/4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-10-05T06:02:08Z" }, -] - [[package]] name = "ollama" version = "0.5.3" @@ -4398,19 +4268,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-gcp-trace/1.15/opentelemetry_exporter_gcp_trace-1.15.0-py3-none-any.whl", hash = "sha256:bab0bda41a74c9ece16611da2e1dd442d9d6f45238a734e62fee796e2c3b122c" }, ] -[[package]] -name = "opentelemetry-exporter-otlp" -version = "1.39.1" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-otlp/1.39.1/opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-otlp/1.39.1/opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe" }, -] - [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.39.1" @@ -4423,24 +4280,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-otlp-proto-common/1.39.1/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde" }, ] -[[package]] -name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.39.1" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-otlp-proto-grpc/1.39.1/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-otlp-proto-grpc/1.39.1/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18" }, -] - [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.39.1" @@ -4459,21 +4298,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-exporter-otlp-proto-http/1.39.1/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985" }, ] -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-instrumentation/0.60b1/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/opentelemetry-instrumentation/0.60b1/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d" }, -] - [[package]] name = "opentelemetry-proto" version = "1.39.1" @@ -4539,74 +4363,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orderedmultidict/1.0.2/orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b" }, ] -[[package]] -name = "orjson" -version = "3.11.7" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/orjson/3.11.7/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d" }, -] - [[package]] name = "packaging" version = "26.3" @@ -4844,34 +4600,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/protobuf/6.33.5/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02" }, ] -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/psutil/7.2.2/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee" }, -] - [[package]] name = "pyarrow" version = "25.0.1" @@ -5211,6 +4939,20 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/pytest-asyncio/1.3/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/pytest-cov/7.1/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-08-16T16:19:23Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/pytest-cov/7.1/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-07-24T03:31:19Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1" @@ -5348,6 +5090,15 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/pywin32/311/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/pywin32-ctypes/0.2.3/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2026-08-28T23:52:01Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/pywin32-ctypes/0.2.3/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2026-07-24T02:53:17Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -5421,6 +5172,20 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/qdrant-client/1.17.1/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd" }, ] +[[package]] +name = "readme-renderer" +version = "46.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/readme-renderer/46/readme_renderer-46.0.tar.gz", hash = "sha256:af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90", size = 38382, upload-time = "2026-09-04T21:43:33Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/readme-renderer/46/readme_renderer-46.0-py3-none-any.whl", hash = "sha256:d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560", size = 14239, upload-time = "2026-09-04T15:32:48Z" }, +] + [[package]] name = "redis" version = "7.1.1" @@ -5466,110 +5231,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/referencing/0.36.2/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0" }, ] -[[package]] -name = "regex" -version = "2026.4.4" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/regex/2026.4.4/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d" }, -] - [[package]] name = "requests" version = "2.33.0" @@ -5585,19 +5246,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/requests/2.33/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b" }, ] -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/requests-oauthlib/2/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/requests-oauthlib/2/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36" }, -] - [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -5622,6 +5270,28 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/rfc3339-validator/0.1.4/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa" }, ] +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/rfc3986/2/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2026-09-04T21:39:04Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/rfc3986/2/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2026-07-28T11:01:35Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/rich/15/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-08-04T05:31:09Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/rich/15/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-07-24T00:40:27Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -5847,6 +5517,19 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/scipy/1.17/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/secretstorage/3.5/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2026-09-09T05:06:33Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/secretstorage/3.5/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2026-07-24T14:03:31Z" }, +] + [[package]] name = "semantic-kernel" version = "1.44.1" @@ -6014,60 +5697,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tenacity/9.1.4/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, ] -[[package]] -name = "tiktoken" -version = "0.12.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tiktoken/0.12/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" }, -] - [[package]] name = "tomli-w" version = "1.2.0" @@ -6144,6 +5773,26 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/tqdm/4.67.3/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, ] +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/twine/6.2/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17Z" } +wheels = [ + { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/twine/6.2/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2026-07-27T21:45:25Z" }, +] + [[package]] name = "types-requests" version = "2.32.4.20260107" @@ -6216,35 +5865,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/urllib3/2.8/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-22T19:29:35Z" }, ] -[[package]] -name = "uuid-utils" -version = "0.14.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/uuid-utils/0.14/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2" }, -] - [[package]] name = "uv" version = "0.13.4" @@ -6517,168 +6137,6 @@ wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/werkzeug/3.1.9/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-10-04T18:34:00Z" }, ] -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/wrapt/1.17.3/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22" }, -] - -[[package]] -name = "xxhash" -version = "3.6.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/xxhash/3.6/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d" }, -] - [[package]] name = "yarl" version = "1.25.1" @@ -6820,77 +6278,3 @@ sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934 wheels = [ { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zipp/3.23/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" }, ] - -[[package]] -name = "zstandard" -version = "0.25.0" -source = { registry = "https://packagefeedproxy.microsoft.io/pypi/simple/" } -sdist = { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b" } -wheels = [ - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2" }, - { url = "https://ms-feed-25.pkgs.visualstudio.com/6f084628-a36d-42cb-934d-057357e379dc/_packaging/49d7402f-07bb-4b18-a9ce-086e6e98a554/pypi/download/zstandard/0.25/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d" }, -] diff --git a/versioning/TARGET-VERSION b/versioning/TARGET-VERSION index 7ec1d6db..4a36342f 100644 --- a/versioning/TARGET-VERSION +++ b/versioning/TARGET-VERSION @@ -1 +1 @@ -2.1.0 +3.0.0