diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index ffcdefa540a..3b04ba06be6 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -34,19 +34,19 @@
# Repository-level paths: every core Agent Framework developer is a code owner, so any
# one of them can approve. Be explicit now and we can use the AgentFramework team in the future.
-/docs/ @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
-/*.md @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
-/LICENSE @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
-/.gitattributes @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
-/.gitignore @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
-/.github @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh
+/docs/ @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
+/*.md @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
+/LICENSE @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
+/.gitattributes @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
+/.gitignore @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
+/.github @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
# Repository-level paths that require specific owners
/.devcontainer @chetantoshniwal @westey-m @rogerbarreto @SergeyMenshykh
-/declarative-agents @chetantoshniwal @moonbox3 @peibekwe
+/declarative-agents @chetantoshniwal @moonbox3 @eavanvalkenburg @peibekwe @baywet
-# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
-/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
+# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl
+/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl
# Python packages
/python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
@@ -60,50 +60,51 @@
/python/packages/chatkit/ @chetantoshniwal @eavanvalkenburg @moonbox3 @giles17
/python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
-/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17
-/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU
+/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17
+/python/packages/core/agent_framework/_vectors.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh
+/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl
/python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3
-/python/packages/declarative/ @chetantoshniwal @eavanvalkenburg @moonbox3 @peibekwe
+/python/packages/declarative/ @chetantoshniwal @eavanvalkenburg @moonbox3 @peibekwe @baywet
/python/packages/devui/ @chetantoshniwal @eavanvalkenburg @moonbox3
-/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 @giles17
-/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @eavanvalkenburg @moonbox3
+/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 @giles17
+/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @jpalvarezl @eavanvalkenburg @moonbox3
/python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
/python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3
-/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
-/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
-/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
-/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
-/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3
+/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3
+/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3
+/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3
+/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3
+/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3
/python/packages/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
-/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU
+/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl
/python/packages/mem0/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/mistral/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/monty/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/ollama/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
-/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17
-/python/packages/orchestrations/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU
+/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17
+/python/packages/orchestrations/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl
/python/packages/purview/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/redis/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
/python/packages/tools/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3
-# Core .NET developers: @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
-/dotnet @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
+# Core .NET developers: @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
+/dotnet @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
# .NET projects
-/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
-/dotnet/src/LegacySupport/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
-/dotnet/src/Shared/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
+/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
+/dotnet/src/LegacySupport/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
+/dotnet/src/Shared/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Abstractions/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
-/dotnet/src/Microsoft.Agents.AI.AGUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
+/dotnet/src/Microsoft.Agents.AI.AGUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Anthropic/ @chetantoshniwal @rogerbarreto @westey-m
/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/ @chetantoshniwal @rogerbarreto @westey-m
/dotnet/src/Microsoft.Agents.AI.CopilotStudio/ @chetantoshniwal @rogerbarreto @westey-m
/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/ @chetantoshniwal @rogerbarreto @westey-m
-/dotnet/src/Microsoft.Agents.AI.Declarative/ @chetantoshniwal @peibekwe @westey-m
-/dotnet/src/Microsoft.Agents.AI.DevUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m
+/dotnet/src/Microsoft.Agents.AI.Declarative/ @chetantoshniwal @peibekwe @baywet @westey-m
+/dotnet/src/Microsoft.Agents.AI.DevUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Foundry/ @chetantoshniwal @rogerbarreto @westey-m
/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ @chetantoshniwal @rogerbarreto @westey-m
/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/ @chetantoshniwal @rogerbarreto @westey-m
@@ -117,14 +118,14 @@
/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m
/dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh
/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh
-/dotnet/src/Microsoft.Agents.AI.Mcp/ @chetantoshniwal @westey-m @peibekwe
+/dotnet/src/Microsoft.Agents.AI.Mcp/ @chetantoshniwal @westey-m @peibekwe @baywet
/dotnet/src/Microsoft.Agents.AI.Mem0/ @chetantoshniwal @westey-m @SergeyMenshykh
/dotnet/src/Microsoft.Agents.AI.OpenAI/ @chetantoshniwal @westey-m @rogerbarreto
/dotnet/src/Microsoft.Agents.AI.Purview/ @chetantoshniwal @westey-m @SergeyMenshykh
/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ @chetantoshniwal @westey-m @SergeyMenshykh
/dotnet/src/Microsoft.Agents.AI.Valkey/ @chetantoshniwal @westey-m @SergeyMenshykh
-/dotnet/src/Microsoft.Agents.AI.Workflows/ @chetantoshniwal @peibekwe @rogerbarreto
-/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ @chetantoshniwal @peibekwe @rogerbarreto
-/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @rogerbarreto
-/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @rogerbarreto
-/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @rogerbarreto
+/dotnet/src/Microsoft.Agents.AI.Workflows/ @chetantoshniwal @peibekwe @baywet @rogerbarreto
+/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ @chetantoshniwal @peibekwe @baywet @rogerbarreto
+/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @baywet @rogerbarreto
+/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @baywet @rogerbarreto
+/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @baywet @rogerbarreto
diff --git a/.github/actions/github-app-token/action.yml b/.github/actions/github-app-token/action.yml
index d5afe8b32d6..519d9b44b5e 100644
--- a/.github/actions/github-app-token/action.yml
+++ b/.github/actions/github-app-token/action.yml
@@ -59,7 +59,7 @@ runs:
id: azure-login
if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }}
continue-on-error: true
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
diff --git a/.github/actions/sample-validation-setup/action.yml b/.github/actions/sample-validation-setup/action.yml
index 531ae6b6504..020823448f0 100644
--- a/.github/actions/sample-validation-setup/action.yml
+++ b/.github/actions/sample-validation-setup/action.yml
@@ -24,7 +24,7 @@ runs:
using: "composite"
steps:
- name: Set up Node.js environment
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
@@ -59,7 +59,7 @@ runs:
sample-playbooks-${{ github.job }}-
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a2e846a207f..5fd3c73b898 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -11,6 +11,27 @@ updates:
schedule:
interval: "cron"
cronjob: "0 8 * * 4,0" # Every Thursday(4) and Sunday(0) at 8:00 UTC
+ groups:
+ agent-memory:
+ patterns:
+ - "AgentMemory"
+ - "AgentMemory.AgentFramework"
+ aspire:
+ patterns:
+ - "Aspire.*"
+ - "CommunityToolkit.Aspire.*"
+ azure-ai-agentserver:
+ patterns:
+ - "Azure.AI.AgentServer.*"
+ agui:
+ patterns:
+ - "AGUI.*"
+ communitytoolkit-vectordata:
+ patterns:
+ - "CommunityToolkit.VectorData.*"
+ anthropic:
+ patterns:
+ - "Anthropic*"
ignore:
# For all System.* and Microsoft.Extensions/Bcl.* packages, ignore all major version updates
- dependency-name: "System.*"
@@ -34,6 +55,28 @@ updates:
schedule:
interval: "weekly"
day: "thursday"
+ groups:
+ basics:
+ patterns:
+ - "uv"
+ - "ruff"
+ - "prek"
+ - "poethepoet"
+ - "tomli"
+ - "rich"
+ pytest:
+ patterns:
+ - "pytest*"
+ flit:
+ patterns:
+ - "flit*"
+ python-type-checkers:
+ patterns:
+ - "mypy"
+ - "pyright"
+ - "pyrefly"
+ - "ty"
+ - "zuban"
labels:
- "python"
- "dependencies"
@@ -45,6 +88,28 @@ updates:
schedule:
interval: "weekly"
day: "thursday"
+ groups:
+ basics:
+ patterns:
+ - "uv"
+ - "ruff"
+ - "prek"
+ - "poethepoet"
+ - "tomli"
+ - "rich"
+ pytest:
+ patterns:
+ - "pytest*"
+ flit:
+ patterns:
+ - "flit*"
+ python-type-checkers:
+ patterns:
+ - "mypy"
+ - "pyright"
+ - "pyrefly"
+ - "ty"
+ - "zuban"
labels:
- "python"
- "dependencies"
@@ -62,6 +127,14 @@ updates:
directories:
- "/"
- "/.github/actions/*"
+ groups:
+ codeql-actions:
+ patterns:
+ - "github/codeql-action/*"
+ artifact-actions:
+ patterns:
+ - "actions/upload-artifact"
+ - "actions/download-artifact"
schedule:
interval: "weekly"
day: "sunday"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 40a2e12df20..62245624129 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -38,7 +38,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
+ uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
# âšī¸ Command-line programs to run using the OS shell.
# đ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -64,6 +64,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml
index 03eddad556c..592ef3caa34 100644
--- a/.github/workflows/devflow-pr-review.yml
+++ b/.github/workflows/devflow-pr-review.yml
@@ -86,7 +86,7 @@ jobs:
- name: Check review command
id: check
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const isReviewCommand = require('./.github/scripts/review_command.js');
@@ -163,7 +163,7 @@ jobs:
- name: Check review requester team membership
id: check
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
MEMBERSHIP_USER: >-
${{
@@ -194,7 +194,7 @@ jobs:
- name: React to authorized review command
if: ${{ github.event_name == 'issue_comment' && steps.check.outputs.is_team_member == 'true' }}
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.github-auth.outputs.token }}
script: |
@@ -241,7 +241,7 @@ jobs:
path: devflow
- name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml
index 90b5bff88ed..4c90100af1c 100644
--- a/.github/workflows/dotnet-build-and-test.yml
+++ b/.github/workflows/dotnet-build-and-test.yml
@@ -41,7 +41,7 @@ jobs:
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
@@ -418,7 +418,7 @@ jobs:
- name: Azure CLI Login
if: github.event_name != 'pull_request' && matrix.integration-tests
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -560,7 +560,7 @@ jobs:
run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -600,12 +600,9 @@ jobs:
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }}
- # Azure AI Search (for the azure-search-rag scenario). Reuses the integration
- # environment secrets shared with python-sample-validation.yml. The index is
- # provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
- # for the required schema and seed content.
+ # Use the dedicated hosted-agent Search index for both Search scenarios.
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
- AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
+ AZURE_SEARCH_INDEX_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
@@ -640,14 +637,14 @@ jobs:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
id: check_tests_cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: core.setFailed('Integration Tests Cancelled!')
diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml
index 939033a3517..3bbdfb2f3c4 100644
--- a/.github/workflows/dotnet-integration-tests.yml
+++ b/.github/workflows/dotnet-integration-tests.yml
@@ -77,7 +77,7 @@ jobs:
done
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml
index e101939d41b..e24c4233275 100644
--- a/.github/workflows/dotnet-verify-samples.yml
+++ b/.github/workflows/dotnet-verify-samples.yml
@@ -58,7 +58,7 @@ jobs:
- name: Azure CLI Login
if: github.event_name != 'pull_request'
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
diff --git a/.github/workflows/github-automation-tests.yml b/.github/workflows/github-automation-tests.yml
index a951455398c..e03b1da575f 100644
--- a/.github/workflows/github-automation-tests.yml
+++ b/.github/workflows/github-automation-tests.yml
@@ -27,11 +27,11 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
diff --git a/.github/workflows/integration-tests-manual.yml b/.github/workflows/integration-tests-manual.yml
index 8a6d415e814..aca4522a6ea 100644
--- a/.github/workflows/integration-tests-manual.yml
+++ b/.github/workflows/integration-tests-manual.yml
@@ -50,7 +50,7 @@ jobs:
- name: Resolve and authorize checkout ref
id: resolve
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml
index af17d8cfcd1..d6636a78034 100644
--- a/.github/workflows/issue-triage.yml
+++ b/.github/workflows/issue-triage.yml
@@ -19,7 +19,7 @@ concurrency:
group: >-
issue-triage-${{ github.repository }}-${{
github.event_name == 'workflow_dispatch' && inputs.issue_number
- || github.event.issue.type.name == 'Bug' && github.event.issue.number
+ || github.event_name == 'issues' && github.event.issue.number
|| github.run_id
}}
cancel-in-progress: true
@@ -39,11 +39,13 @@ jobs:
${{
github.event_name == 'workflow_dispatch'
|| github.event.issue.type.name == 'Bug'
+ || github.event.issue.type.name == 'Feature'
}}
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
repo: ${{ steps.issue.outputs.repo }}
+ issue_type: ${{ steps.issue-type.outputs.issue_type }}
steps:
- name: Resolve issue metadata
id: issue
@@ -94,7 +96,7 @@ jobs:
- name: Check issue author team membership
if: ${{ github.event_name != 'workflow_dispatch' }}
id: check
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
@@ -116,6 +118,36 @@ jobs:
core.info(`Author ${author} is not a team member; proceeding with triage.`);
}
+ - name: Resolve native issue type
+ id: issue-type
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
+ with:
+ github-token: ${{ steps.github-auth.outputs.token }}
+ script: |
+ const issueNumber = Number(process.env.ISSUE_NUMBER);
+ const eventType = context.payload.issue?.type?.name;
+ if (eventType) {
+ core.setOutput('issue_type', eventType);
+ return;
+ }
+
+ const result = await github.graphql(`
+ query ResolveNativeIssueType($owner: String!, $repo: String!, $number: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $number) {
+ issueType { name }
+ }
+ }
+ }
+ `, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ number: issueNumber,
+ });
+ core.setOutput('issue_type', result.repository?.issue?.issueType?.name || '');
+
triage:
runs-on: ubuntu-latest
needs: team_check
@@ -153,7 +185,7 @@ jobs:
path: devflow
- name: Set up Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
@@ -168,7 +200,7 @@ jobs:
run: uv sync --frozen
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -198,7 +230,7 @@ jobs:
echo "Stopping: issue triage preflight did not allow automation."
exit 1
- - name: Reproduce reported issue
+ - name: Triage reported issue
if: ${{ steps.spam.outputs.allow_triage == 'true' }}
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
@@ -211,6 +243,7 @@ jobs:
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
+ ISSUE_TYPE: ${{ needs.team_check.outputs.issue_type }}
# Model-provider settings for generated repro code. Never enter the
# agent prompt; consumed by SDK constructors via os.environ. Azure
# OpenAI and Foundry auth via AAD from the azure/login step above.
@@ -237,4 +270,5 @@ jobs:
uv run python scripts/trigger_issue_repro.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
+ --issue-type "$ISSUE_TYPE" \
--github-username "$GITHUB_ACTOR"
diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml
index d420e922d1f..d514c7a38f0 100644
--- a/.github/workflows/label-issues.yml
+++ b/.github/workflows/label-issues.yml
@@ -40,7 +40,7 @@ jobs:
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.github-auth.outputs.token }}
script: |
diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml
index 165811175d8..8952194b36c 100644
--- a/.github/workflows/label-pr.yml
+++ b/.github/workflows/label-pr.yml
@@ -46,12 +46,12 @@ jobs:
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
+ - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
repo-token: ${{ steps.github-auth.outputs.token }}
- name: "PR: add breaking change label from title"
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.github-auth.outputs.token }}
script: |
diff --git a/.github/workflows/label-title-prefix.yml b/.github/workflows/label-title-prefix.yml
index a830b8aa13b..3a9cd542de9 100644
--- a/.github/workflows/label-title-prefix.yml
+++ b/.github/workflows/label-title-prefix.yml
@@ -22,7 +22,7 @@ jobs:
fetch-depth: 1
persist-credentials: false
- - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
name: "Issue/PR: update title"
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/limit-community-prs.yml b/.github/workflows/limit-community-prs.yml
index e2e11f902e7..12e40018612 100644
--- a/.github/workflows/limit-community-prs.yml
+++ b/.github/workflows/limit-community-prs.yml
@@ -53,7 +53,7 @@ jobs:
- name: Check PR author team membership
id: check
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -107,7 +107,7 @@ jobs:
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- name: Enforce open PR limit
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.github-auth.outputs.token }}
script: |
diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml
index 6d1635837cb..9a43b516ef0 100644
--- a/.github/workflows/markdown-link-check.yml
+++ b/.github/workflows/markdown-link-check.yml
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Set up Node.js
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
diff --git a/.github/workflows/merge-gatekeeper.yml b/.github/workflows/merge-gatekeeper.yml
index cf55dbeb49f..8aadcace48b 100644
--- a/.github/workflows/merge-gatekeeper.yml
+++ b/.github/workflows/merge-gatekeeper.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Wait for required checks
if: github.event_name == 'pull_request'
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TIMEOUT_SECONDS: "3600"
INTERVAL_SECONDS: "30"
diff --git a/.github/workflows/promote-shipped-apis.yml b/.github/workflows/promote-shipped-apis.yml
index 52a20104473..fb4d44ffe41 100644
--- a/.github/workflows/promote-shipped-apis.yml
+++ b/.github/workflows/promote-shipped-apis.yml
@@ -7,8 +7,7 @@ on:
workflow_dispatch:
permissions:
- contents: write
- pull-requests: write
+ contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -19,23 +18,42 @@ jobs:
runs-on: ubuntu-latest
steps:
+ - name: Generate GitHub App token
+ id: app-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ secrets.GHA_TOKEN_PROVIDER_APP_ID }}
+ private-key: ${{ secrets.GHA_TOKEN_PROVIDER_PEM }}
+ permission-contents: write
+ permission-pull-requests: write
+
+ - name: Get GitHub App user ID
+ id: get-user-id
+ shell: bash
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
+
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- token: ${{ secrets.GITHUB_TOKEN }}
+ token: ${{ steps.app-token.outputs.token }}
- name: Configure git
shell: pwsh
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]"
+ git config user.email "${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
+ git config --global url."https://$($env:GH_TOKEN)@github.com/".insteadOf "https://github.com/"
- name: Check for existing PR
id: check_pr
shell: pwsh
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
$branch = "${{ github.ref_name }}"
$prBranch = "promote-shipped-apis-$branch"
@@ -95,7 +113,7 @@ jobs:
if: steps.check_pr.outputs.pr_exists == 'false' && steps.check_changes.outputs.has_changes == 'true'
shell: pwsh
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
$branch = "${{ github.ref_name }}"
$prBranch = "promote-shipped-apis-$branch"
diff --git a/.github/workflows/python-dependency-maintenance.yml b/.github/workflows/python-dependency-maintenance.yml
index 7f84170a57e..c680e594ccc 100644
--- a/.github/workflows/python-dependency-maintenance.yml
+++ b/.github/workflows/python-dependency-maintenance.yml
@@ -88,7 +88,7 @@ jobs:
- name: Create issue for failed dependency bounds test
if: steps.validate_bounds_test.outcome != 'success'
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -158,7 +158,7 @@ jobs:
- name: Create issues for failed dependency candidates
if: always()
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -321,7 +321,7 @@ jobs:
- name: Create or update dependency maintenance tracking issue
if: steps.commit_updates.outputs.has_changes == 'true'
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml
index acfd0092087..d802b9c9524 100644
--- a/.github/workflows/python-integration-tests.yml
+++ b/.github/workflows/python-integration-tests.yml
@@ -138,7 +138,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -308,7 +308,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -358,7 +358,7 @@ jobs:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -559,12 +559,12 @@ jobs:
steps:
- name: Fail workflow if tests failed
if: contains(join(needs.*.result, ','), 'failure')
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: core.setFailed('Integration Tests Cancelled!')
diff --git a/.github/workflows/python-lab-tests.yml b/.github/workflows/python-lab-tests.yml
index 5f90e6a9aef..9a0034ab8e8 100644
--- a/.github/workflows/python-lab-tests.yml
+++ b/.github/workflows/python-lab-tests.yml
@@ -25,7 +25,7 @@ jobs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml
index 4d77ef06608..70278b4b80f 100644
--- a/.github/workflows/python-merge-tests.yml
+++ b/.github/workflows/python-merge-tests.yml
@@ -42,7 +42,7 @@ jobs:
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
+ - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
@@ -224,7 +224,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -422,7 +422,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -483,7 +483,7 @@ jobs:
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
- uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
+ uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -720,13 +720,13 @@ jobs:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
id: check_tests_cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: core.setFailed('Integration Tests Cancelled!')
diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml
index 43b4452a9f3..c800e1787da 100644
--- a/.github/workflows/python-release.yml
+++ b/.github/workflows/python-release.yml
@@ -125,7 +125,7 @@ jobs:
uv run poe --directory "packages/$PACKAGE" build
fi
- name: Release
- uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: |
python/dist/*
diff --git a/.github/workflows/stale-issue-pr-ping.yml b/.github/workflows/stale-issue-pr-ping.yml
index 4ce2546cea7..ce983390757 100644
--- a/.github/workflows/stale-issue-pr-ping.yml
+++ b/.github/workflows/stale-issue-pr-ping.yml
@@ -50,7 +50,7 @@ jobs:
repository: ${{ github.repository }}
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8126d945eb6..a041b727d58 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -109,6 +109,20 @@ For more details, see the [Package Validation diagnostic IDs](https://learn.micr
Released .NET packages also use `Microsoft.CodeAnalysis.PublicApiAnalyzers` to make source-level public API changes visible during builds. The `PublicAPI.*.txt` files use `#nullable enable` so nullability annotations are tracked as part of the public API surface. When adding, changing, or removing public APIs in a released package, update the package's `PublicAPI.Unshipped.txt` file with the analyzer-provided entries and include that change in your PR. The build will fail if public API changes are not reflected in the baseline files.
+If local or CI builds report Public API Analyzer warnings or errors, handle each diagnostic separately:
+
+- `RS0016` reports a newly exposed public API that is missing from the baseline. The preferred fix is to use the analyzer code fix on the affected code symbol to add the missing API entry automatically. Alternatively, run `dotnet format` for `RS0016` from the repository root:
+
+ ```powershell
+ dotnet format .\dotnet\agent-framework-dotnet.slnx analyzers --diagnostics RS0016
+ ```
+
+- `RS0017` reports that a declared public API was deleted. Restore the API if the deletion was accidental; otherwise, record the removed signature in the package's `PublicAPI.Unshipped.txt` file with the `*REMOVED*` prefix by using the corresponding code fix, or the following dotnet format script:
+
+ ```powershell
+ dotnet format .\dotnet\agent-framework-dotnet.slnx analyzers --diagnostics RS0017
+ ```
+
After a release, the `Promote Shipped APIs` workflow moves entries from `PublicAPI.Unshipped.txt` to `PublicAPI.Shipped.txt` and opens or updates a promotion PR. Publish builds fail if released packages still contain unshipped public API entries.
### Suggested Workflow
diff --git a/docs/decisions/0033-feature-usage-bitmask-user-agent.md b/docs/decisions/0033-feature-usage-bitmask-user-agent.md
index 059f521c69a..9a0b0b14e8b 100644
--- a/docs/decisions/0033-feature-usage-bitmask-user-agent.md
+++ b/docs/decisions/0033-feature-usage-bitmask-user-agent.md
@@ -532,7 +532,7 @@ Sources: botocore [`useragent.py`](https://github.com/boto/botocore/blob/develop
openai-python [`_base_client.py` `platform_headers()`](https://github.com/openai/openai-python/blob/main/src/openai/_base_client.py);
anthropic-sdk-python [`_base_client.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_base_client.py);
azure-core [`_universal.py` `UserAgentPolicy`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/azure/core/pipeline/policies/_universal.py);
-google-api-core [`client_info.py`](https://github.com/googleapis/python-api-core/blob/main/google/api_core/client_info.py);
+google-api-core [`client_info.py`](https://github.com/googleapis/google-cloud-python/blob/main/packages/google-api-core/google/api_core/client_info.py);
langsmith-sdk [`client.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/client.py) /
[`utils.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/utils.py);
huggingface_hub [`constants.py`](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/constants.py).
diff --git a/docs/features/vector-stores-and-embeddings/README.md b/docs/features/vector-stores-and-embeddings/README.md
index 9f820ad7c79..15e058fd58a 100644
--- a/docs/features/vector-stores-and-embeddings/README.md
+++ b/docs/features/vector-stores-and-embeddings/README.md
@@ -10,7 +10,7 @@ This feature ports the vector store abstractions, embedding generator abstractio
| Vector store collections | CRUD operations on vector store collections (upsert, get, delete) |
| Vector search | Unified search interface with `search_type` parameter (`"vector"`, `"keyword_hybrid"`) |
| Data model decorator | `@vectorstoremodel` decorator for defining vector store data models (supports Pydantic, dataclasses, plain classes, dicts) |
-| Agent tools | `create_search_tool`, `create_upsert_tool`, `create_get_tool`, `create_delete_tool` for agent-usable vector store operations |
+| Agent tools | `create_vector_search_tool`, `create_upsert_tool`, `create_get_tool`, `create_delete_tool` for agent-usable vector store operations |
| In-memory store | Zero-dependency vector store for testing and development |
| 13+ connectors | Azure AI Search, Qdrant, Redis, PostgreSQL, MongoDB, Cosmos DB, Pinecone, Chroma, Weaviate, Oracle, SQL Server, FAISS |
@@ -47,12 +47,12 @@ This feature ports the vector store abstractions, embedding generator abstractio
- **Embedding types** (`Embedding`, `GeneratedEmbeddings`, `EmbeddingGenerationOptions`) in `agent_framework/_types.py`
- **Embedding protocol + base class** (`SupportsGetEmbeddings`, `BaseEmbeddingClient`) in `agent_framework/_clients.py`
- **All vector store specific code** in a new `agent_framework/_vectors.py` module â this includes:
- - Enums: `FieldTypes`, `IndexKind`, `DistanceFunction`
+ - String literal aliases: `FieldTypes`, `IndexKind`, `DistanceFunction`
- `VectorStoreField`, `VectorStoreCollectionDefinition`
- - `SearchOptions`, `SearchResponse`, `RecordFilterOptions`
+ - `SearchResponse`, `SearchResults`, and explicit CRUD/search keyword arguments
- `@vectorstoremodel` decorator
- - Serialization/deserialization protocols
- - `VectorStoreRecordHandler`, `BaseVectorCollection`, `BaseVectorStore`, `BaseVectorSearch`
+ - `register_vectorstoremodel` with msgspec-backed default codecs and optional custom codecs
+ - Internal record conversion shared by `BaseVectorCollection` and `BaseVectorSearch`
- `SupportsVectorUpsert`, `SupportsVectorSearch` protocols
- **OpenAI embeddings** in `agent_framework/openai/` (built into core, like OpenAI chat)
- **Azure OpenAI embeddings** in `agent_framework/azure/` (built into core, follows `AzureOpenAIChatClient` pattern)
@@ -69,9 +69,9 @@ This feature ports the vector store abstractions, embedding generator abstractio
| `VectorStoreCollection` | `BaseVectorCollection` | Drop redundant `Store`, add `Base` prefix per AF pattern |
| `VectorStore` | `BaseVectorStore` | Add `Base` prefix per AF pattern |
| `VectorSearch` | `BaseVectorSearch` | Add `Base` prefix per AF pattern |
-| `VectorSearchOptions` | `SearchOptions` | Shorter â context is already vector search |
+| `VectorSearchOptions` | Explicit `search()` keyword arguments | Avoid an options object that only forwards values |
| `VectorSearchResult` | `SearchResponse` | Align with `ChatResponse`/`AgentResponse` |
-| `GetFilteredRecordOptions` | `RecordFilterOptions` | Shorter, more natural |
+| `GetFilteredRecordOptions` | Explicit `get()` keyword arguments | Avoid an options object that only forwards values |
| `EmbeddingGeneratorBase` | `BaseEmbeddingClient` | Matches AF `BaseChatClient` pattern |
| `VectorStoreCollectionProtocol` | `SupportsVectorUpsert` | AF `Supports*` naming convention |
| `VectorSearchProtocol` | `SupportsVectorSearch` | AF `Supports*` naming convention |
@@ -88,7 +88,6 @@ This feature ports the vector store abstractions, embedding generator abstractio
| `@vectorstoremodel` | `_vectors.py` |
| `VectorStoreField` | `_vectors.py` |
| `VectorStoreCollectionDefinition` | `_vectors.py` |
-| `VectorStoreRecordHandler` | `_vectors.py` |
| `FieldTypes` | `_vectors.py` |
| `IndexKind` | `_vectors.py` |
| `DistanceFunction` | `_vectors.py` |
@@ -107,7 +106,7 @@ This feature ports the vector store abstractions, embedding generator abstractio
| `EmbeddingTelemetryLayer` | `observability.py` | MRO-based OTel tracing for embeddings |
| `SupportsVectorUpsert` | `_vectors.py` | Protocol for collection CRUD |
| `SupportsVectorSearch` | `_vectors.py` | Protocol for vector search |
-| `create_search_tool` | `_vectors.py` | Creates AF `FunctionTool` from vector search |
+| `create_vector_search_tool` | `_vectors.py` | Creates AF `FunctionTool` from vector search |
## Source Files Reference (SK â AF mapping)
@@ -187,41 +186,52 @@ This feature ports the vector store abstractions, embedding generator abstractio
### Phase 3: Core Vector Store Abstractions
**Goal:** Establish all vector store types, enums, the decorator, collection definition, and base classes.
**Mergeable:** Yes â adds new abstractions, no breaking changes.
+**Feature stage:** Experimental (`VECTOR_STORES`).
-#### 3.1 â Vector store enums and field types in `_vectors.py`
-- `FieldTypes` enum: `KEY`, `VECTOR`, `DATA`
-- `IndexKind` enum: `HNSW`, `FLAT`, `IVF_FLAT`, `DISK_ANN`, `QUANTIZED_FLAT`, `DYNAMIC`, `DEFAULT`
-- `DistanceFunction` enum: `COSINE_SIMILARITY`, `COSINE_DISTANCE`, `DOT_PROD`, `EUCLIDEAN_DISTANCE`, `EUCLIDEAN_SQUARED_DISTANCE`, `MANHATTAN`, `HAMMING`, `DEFAULT`
-- No `SearchType` enum â use `Literal["vector", "keyword_hybrid"]` instead, per AF convention of avoiding unnecessary imports
+#### 3.1 â Vector store literal aliases and field types in `_vectors.py`
+- `FieldTypes`: `Literal["key", "vector", "data"]`
+- `IndexKind`: literal alias covering `hnsw`, `flat`, `ivf_flat`, `disk_ann`, `quantized_flat`, `dynamic`, and `default`
+- `DistanceFunction`: literal alias covering the supported similarity and distance functions
+- `SearchType`: `Literal["vector", "keyword_hybrid"]`
- `VectorStoreField` plain class (not Pydantic)
- `VectorStoreCollectionDefinition` class (not Pydantic internally, but supports Pydantic models as input)
-- `SearchOptions` plain class â includes `score_threshold: float | None` for filtering results by score (see note below)
- `SearchResponse` generic class
-- `RecordFilterOptions` plain class
+- `SearchResults` generic result container
+- Explicit keyword arguments on `get()` and `search()` instead of options classes
- `DISTANCE_FUNCTION_DIRECTION_HELPER` dict
#### 3.2 â `@vectorstoremodel` decorator
- Port from SK, works with dataclasses, Pydantic models, plain classes, and dicts
+- Plain classes can declare `VectorStoreField` metadata on annotated `__init__` parameters, matching `@tool`
- Sets `__vectorstoremodel__` and `__vectorstoremodel_definition__` on the class
- Remove SK-specific `kernel` prefix (`__kernel_vectorstoremodel__` â `__vectorstoremodel__`)
-#### 3.3 â Serialization/deserialization protocols
-- `SerializeMethodProtocol`, `ToDictFunctionProtocol`, `FromDictFunctionProtocol`, etc.
-- Port the record handler logic but without Pydantic base class â use plain class or ABC
+#### 3.3 â Registered model codecs
+- `register_vectorstoremodel` registers one collection definition and encoder/decoder pair per model type
+- `@vectorstoremodel` creates the definition and registers msgspec-backed default codecs
+- Dictionary records provide their collection definition directly
+- DataFrames and other row containers convert to sequences of row mappings before using the batch API
+- Custom encoder and decoder callbacks can be overridden independently
+- Array-like values such as NumPy arrays serialize through their `tolist()` method without a NumPy dependency;
+ supply a custom decoder that calls `numpy.array` or `numpy.asarray` when the model should restore an array
#### 3.4 â Vector store base classes in `_vectors.py`
-- `VectorStoreRecordHandler` â internal base class that handles serialization/deserialization between user data models and store-specific formats, plus embedding generation for vector fields. Both `BaseVectorCollection` and `BaseVectorSearch` extend this.
-- `BaseVectorCollection(VectorStoreRecordHandler)` â base for collections
+- `_VectorStoreRecordHandler` â private base class that handles record conversion and embedding generation
+- `BaseVectorCollection` â base for collections
- Uses `SupportsGetEmbeddings` instead of `EmbeddingGeneratorBase`
- Not a Pydantic model â use `__init__` with explicit params
- - `upsert`, `get`, `delete`, `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted`
+ - Batch-oriented `upsert`, `get`, and `delete`
+ - `upsert()` generates vector values by default and requires an embedding generator for every vector field;
+ pass `generate_vectors=False` to preserve supplied vector values
+ - CRUD `get()` excludes vectors by default; pass `include_vectors=True` when stored embeddings are needed
+ - `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted`
- Async context manager support
- `BaseVectorStore` â base for stores
- `get_collection`, `list_collection_names`, `collection_exists`, `ensure_collection_deleted`
- Async context manager support
#### 3.5 â Vector search base class
-- `BaseVectorSearch(VectorStoreRecordHandler)` â base for vector search
+- `BaseVectorSearch` â base for vector search
- Single `search(search_type=...)` method with `search_type: Literal["vector", "keyword_hybrid"]` parameter â no enum, just a literal
- `_inner_search` abstract method for implementations
- Filter building with lambda parser (AST-based)
@@ -234,14 +244,17 @@ This feature ports the vector store abstractions, embedding generator abstractio
- No protocol for `VectorStore` â it's a factory for collections, not a capability to duck-type against
#### 3.7 â Exception types
-- Add vector store exceptions under `IntegrationException` or create new branch
-- `VectorStoreException`, `VectorStoreOperationException`, `VectorSearchException`, `VectorStoreModelException`, etc.
+- Use `ValueError` and `TypeError` for invalid arguments, model definitions, and record conversion
+- Use `NotImplementedError` for connector capabilities that are not supported
+- Use the existing `IntegrationException` and `IntegrationInvalidResponseException` at connector boundaries
-#### 3.8 â `create_search_tool` on `BaseVectorSearch`
-- Method on `BaseVectorSearch` that creates an AF `FunctionTool` from the vector search
+#### 3.8 â `create_vector_search_tool`
+- Standalone factory that creates an AF `FunctionTool` from any `SupportsVectorSearch` implementation
- Wraps the single `search()` method, passing `search_type` parameter
-- Accepts: `name`, `description`, `search_type`, `top`, `skip`, `filter`, `string_mapper`
-- The tool takes a query string, vectorizes it, searches, and returns results as strings
+- Accepts: `name`, `description`, `approval_mode`, `search_type`, `parameters`, `top`, `skip`, `filter`, `filter_mapper`, `result_mapper`
+- Defaults to `query`; a custom Pydantic model or JSON schema can expose `top`, `skip`, and additional filter fields
+- Custom schemas must require a string `query`; exposed `top` and `skip` fields must declare finite maximum values
+- The tool vectorizes the query, searches, and maps results to text or multimodal `Content`
- Can also be a standalone factory function in `_vectors.py`
#### 3.9 â Tests for all vector store abstractions
@@ -326,7 +339,7 @@ Each connector follows the AF package structure:
#### 8.1 â `create_upsert_tool` â tool for upserting records into a collection
#### 8.2 â `create_get_tool` â tool for retrieving records by key
- Key-based lookup only (by primary key), not a search tool
-- Documentation must clearly distinguish this from `create_search_tool`: get_tool retrieves specific records by their known key, while search_tool performs similarity/filtered search across the collection
+- Documentation must clearly distinguish this from `create_vector_search_tool`: get_tool retrieves specific records by their known key, while the search tool performs similarity/filtered search across the collection
- Consider if this overlaps with filtered search and document when to use which
#### 8.3 â `create_delete_tool` â tool for deleting records by key
#### 8.4 â Tests and samples for CRUD tools
@@ -349,7 +362,7 @@ Each connector follows the AF package structure:
**Mergeable:** Yes â independent of vector stores.
#### 10.1 â TextSearch base class and types
-- `SearchOptions`, `SearchResponse`, `TextSearchResult`
+- `SearchResponse`, `TextSearchResult`, and explicit search keyword arguments
- `TextSearch` base class with `search()` method
- `create_search_function()` for kernel integration (may need AF equivalent)
@@ -361,7 +374,7 @@ Each connector follows the AF package structure:
## Key Considerations
-1. **No Pydantic for internal classes**: All AF internal classes should use plain classes. Pydantic is only used for user-facing input validation (e.g., vector store data models).
+1. **msgspec-backed conversion**: Use msgspec as the default serialization/deserialization path. Pydantic and plain classes remain supported user-model adapters.
2. **Protocol + Base class**: Follow AF's pattern of both a `Protocol` for duck-typing and a `Base` ABC for implementation, matching how `SupportsChatGetResponse` + `BaseChatClient` works.
@@ -375,11 +388,11 @@ Each connector follows the AF package structure:
7. **Reusable data models**: The `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should be agnostic enough to work with both SK and AF. The core types (`FieldTypes`, `IndexKind`, `DistanceFunction`, `VectorStoreField`) should be identical or easily mapped.
-8. **`create_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` (via the `@tool` decorator pattern) from a vector search. This allows agents to use vector search as a tool during conversations. Design:
- - `create_search_tool(name, description, search_type, ...)` â returns a `FunctionTool` that wraps `VectorSearch.search(search_type=...)`
- - The tool accepts a query string, performs embedding + vector search, and returns results as strings
- - Supports configurable string mappers, filter functions, top/skip defaults
- - Lives in `_vectors.py` as a method on `BaseVectorSearch` and/or as a standalone factory function
+8. **`create_vector_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` from any `SupportsVectorSearch` implementation. This allows agents to use vector search as a tool during conversations. Design:
+ - `create_vector_search_tool(search, name, description, search_type, ...)` returns a `FunctionTool`
+ - The tool accepts declared parameters, performs embedding + vector search, and returns text or multimodal content
+ - Defaults to `query`; custom parameters can expose `top`, `skip`, and additional fields for the filter mapper
+ - Lives in `_vectors.py` without expanding the structural search protocol
9. **CRUD tools**: A full set of create/read/update/delete tools for vector store collections, allowing agents to manage data in vector stores. Design:
- `create_upsert_tool(...)` â tool for upserting records
@@ -387,4 +400,4 @@ Each connector follows the AF package structure:
- `create_delete_tool(...)` â tool for deleting records
- These are separate from search and are placed in a later phase
-10. **Score threshold filtering**: `SearchOptions` includes `score_threshold: float | None` to filter search results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise.
+10. **Score threshold filtering**: `search(score_threshold=...)` filters results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise.
diff --git a/docs/specs/feature-usage-bit-registry.md b/docs/specs/feature-usage-bit-registry.md
index 6b89e4988fb..1e955610acf 100644
--- a/docs/specs/feature-usage-bit-registry.md
+++ b/docs/specs/feature-usage-bit-registry.md
@@ -137,7 +137,8 @@ only to approved first-party endpoints.
| 16 | `core.mcp_skills_source` | MCP-backed skills | `agent_framework.MCPSkillsSource` |
| 17 | `core.session_store` | Agent session store | `agent_framework.SessionStore` / `FileSessionStore` |
| 18 | `core.agent_hooks` | Agent Hooks middleware | `agent_framework.create_agent_hooks_middleware` |
-| 19â31 | _reserved_ | core growth | â |
+| 19 | `core.vector_stores` | Vector store abstractions | `BaseVectorCollection` / `BaseVectorSearch` operations |
+| 20â31 | _reserved_ | core growth | â |
| 32 | `orchestration.sequential` | Sequential orchestration | `agent_framework_orchestrations.SequentialBuilder` |
| 33 | `orchestration.concurrent` | Concurrent orchestration | `agent_framework_orchestrations.ConcurrentBuilder` |
| 34 | `orchestration.group_chat` | Group-chat orchestration | `agent_framework_orchestrations.GroupChatBuilder` |
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 7d8852c2659..c9346739261 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -7,11 +7,11 @@
- 13.5.2
+ 13.5.3
-
+
diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj
index 4224265ea15..f9521492ef3 100644
--- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj
+++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj
@@ -38,8 +38,8 @@
-
-
+
+
diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md
index 9755e8ba6b7..892d346c4f4 100644
--- a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md
+++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md
@@ -17,7 +17,8 @@ It builds on Post 1's personal finance assistant and teaches it to work with *yo
> â ī¸ **Security â avoid tool-name collisions:** auto-approval rules such as
> `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` match tool calls **solely by tool name**. Any
- > other registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
+ > other registered tool that shares one of the approved names (`file_access_read`,
+ > `file_access_read_lines`, `file_access_ls`,
> `file_access_grep`) would be silently auto-approved, bypassing the human
> approval boundary. Ensure no other tool's name collides with the reserved names a rule approves.
- **Durable memory, two ways:**
diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md
index 02b15847e9b..2415efdb3be 100644
--- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md
+++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md
@@ -55,7 +55,8 @@ E.g. try the following prompt `Please process the sales.csv file by first filter
This sample uses `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` to auto-approve read-only file
access tools. Built-in auto-approval rules match tool calls **solely by tool name**, so any other
-registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`,
+registered tool that shares one of the approved names (`file_access_read`, `file_access_read_lines`,
+`file_access_ls`,
`file_access_grep`) would be **silently auto-approved**, bypassing the
human approval boundary. Ensure no other tool's name collides with the reserved names an
auto-approval rule approves.
diff --git a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs
index 2bfd6d51d72..8f515b7d866 100644
--- a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs
+++ b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs
@@ -1,5 +1,7 @@
īģŋ// Copyright (c) Microsoft. All rights reserved.
+using System.Text.Json;
+using System.Text.Json.Serialization;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Identity;
@@ -15,12 +17,12 @@ namespace Demo.Workflows.Declarative.AotCheckpointing;
///
/// Demonstrates JSON checkpointing of a declarative workflow under reflection-disabled
-/// (the AOT / trim-aggressive constraint set
+/// (the AOT / trim-aggressive constraint set
/// via JsonSerializerIsReflectionEnabledByDefault=false in the csproj).
///
///
-/// The key call is
-/// with . Drop the options argument to observe the AOT failure. See README.
+/// The key call is with
+/// . Drop the options argument to observe the AOT failure. See README.
///
internal sealed class Program
{
@@ -31,14 +33,14 @@ public static async Task Main(string[] args)
await CreateGreeterAgentAsync(foundryEndpoint, configuration);
- string workflowInput = Application.GetInput(args);
+ WorkflowInput workflowInput = new(Application.GetInput(args));
Workflow CreateWorkflow()
{
AzureAgentProvider agentProvider = new(foundryEndpoint, new AzureCliCredential());
DeclarativeWorkflowOptions options = new(agentProvider) { Configuration = configuration };
string workflowPath = Path.Combine(AppContext.BaseDirectory, "AotCheckpointing.yaml");
- return DeclarativeWorkflowBuilder.Build(workflowPath, options);
+ return DeclarativeWorkflowBuilder.Build(workflowPath, options, TransformInput);
}
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-HHmmss-ff}"));
@@ -74,7 +76,10 @@ Workflow CreateWorkflow()
}
}
- private static async Task> RunAndStreamAsync(Workflow workflow, string input, CheckpointManager checkpointManager)
+ private static ChatMessage TransformInput(WorkflowInput input) =>
+ new(ChatRole.User, JsonSerializer.Serialize(input, AotCheckpointingJsonContext.Default.WorkflowInput));
+
+ private static async Task> RunAndStreamAsync(Workflow workflow, WorkflowInput input, CheckpointManager checkpointManager)
{
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input, checkpointManager).ConfigureAwait(false);
return await DrainAsync(run).ConfigureAwait(false);
@@ -172,3 +177,8 @@ private static void TryDelete(DirectoryInfo directory)
}
}
}
+
+internal sealed record WorkflowInput(string Message);
+
+[JsonSerializable(typeof(WorkflowInput))]
+internal sealed partial class AotCheckpointingJsonContext : JsonSerializerContext;
diff --git a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md
index 9856274bfc8..b601f40299d 100644
--- a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md
+++ b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md
@@ -25,15 +25,54 @@ reflection-disabled `System.Text.Json` -- the same constraint imposed by
return is the proof JSON **reads** round-trip too. The resumed run
is disposed immediately; without a pending external request it
would park in `WaitForInputAsync` indefinitely.
+- The initial workflow input is a `WorkflowInput` record. `TransformInput` uses the
+ source-generated `AotCheckpointingJsonContext` to serialize it into a
+ `ChatMessage` before the declarative workflow runs.
`DeclarativeWorkflowJsonOptions` is marked
`[Experimental("MAAI001")]`. Suppress that diagnostic in your csproj to
use it.
-### Registering user-defined types
+### Initial input and checkpoint serialization are separate
-For workflows whose inputs or custom `ActionExecutorResult.Result`
-payloads are user-defined, clone `Default` and append your own resolver:
+`DeclarativeWorkflowBuilder.Build` accepts an optional `inputTransform` delegate.
+For a non-`ChatMessage` input, the default behavior is to call `ToString()`; the
+checkpoint serializer is not involved in this conversion. Use a source-generated
+context (or your own `JsonSerializerOptions`) in the delegate when the workflow
+should receive a JSON representation of a typed input:
+
+```csharp
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Agents.AI.Workflows.Declarative;
+using Microsoft.Extensions.AI;
+
+internal sealed record WorkflowInput(string Message);
+
+[JsonSerializable(typeof(WorkflowInput))]
+internal sealed partial class AppJsonContext : JsonSerializerContext;
+
+Workflow workflow = DeclarativeWorkflowBuilder.Build(
+ workflowPath,
+ options,
+ input => new ChatMessage(
+ ChatRole.User,
+ JsonSerializer.Serialize(input, AppJsonContext.Default.WorkflowInput)));
+```
+
+This sample uses `AotCheckpointingJsonContext` for that initial-input transform.
+The separate `DeclarativeWorkflowJsonOptions.Default` passed to
+`CheckpointManager.CreateJson` supplies type information for declarative workflow
+checkpoint state. If checkpoint state also contains application-defined payloads,
+clone those options and append the application's resolver as shown below; adding
+the resolver to checkpoint options does not automatically change the initial input.
+
+### Registering user-defined checkpoint types
+
+For custom `ActionExecutorResult.Result` payloads or other user-defined values
+that are persisted in workflow checkpoint state, clone `Default` and append your
+own resolver:
```csharp
JsonSerializerOptions options = new(DeclarativeWorkflowJsonOptions.Default);
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
index 27be4dcdea4..e7506da5f1e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
@@ -15,6 +15,14 @@
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
+using OpenAIContainerFileCitationMessageAnnotation = OpenAI.Responses.ContainerFileCitationMessageAnnotation;
+using OpenAIFileCitationMessageAnnotation = OpenAI.Responses.FileCitationMessageAnnotation;
+using OpenAIFilePathMessageAnnotation = OpenAI.Responses.FilePathMessageAnnotation;
+using OpenAIMessageResponseItem = OpenAI.Responses.MessageResponseItem;
+using OpenAIStreamingResponseOutputItemAddedUpdate = OpenAI.Responses.StreamingResponseOutputItemAddedUpdate;
+using OpenAIStreamingResponseOutputItemDoneUpdate = OpenAI.Responses.StreamingResponseOutputItemDoneUpdate;
+using OpenAIStreamingResponseOutputTextAnnotationAddedUpdate = OpenAI.Responses.StreamingResponseOutputTextAnnotationAddedUpdate;
+using OpenAIStreamingResponseOutputTextDeltaUpdate = OpenAI.Responses.StreamingResponseOutputTextDeltaUpdate;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -100,13 +108,14 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents
continue;
}
+ string? currentMessageId = ResolveMessageId(update);
foreach (var content in update.Contents)
{
switch (content)
{
case MeaiTextContent textContent:
{
- if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null)
+ if (!IsSameMessage(currentMessageId, previousMessageId) && currentMessageBuilder is not null)
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations))
{
@@ -119,7 +128,10 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents
accumulatedAnnotations = null;
}
- previousMessageId = update.MessageId;
+ if (currentMessageId is { Length: > 0 })
+ {
+ previousMessageId = currentMessageId;
+ }
if (currentMessageBuilder is null)
{
@@ -138,14 +150,6 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents
yield return currentTextBuilder!.EmitDelta(textContent.Text);
}
- if (textContent.Annotations is { Count: > 0 })
- {
- foreach (var sdkAnnotation in ConvertToSdkAnnotations(textContent.Annotations))
- {
- (accumulatedAnnotations ??= []).Add(sdkAnnotation);
- }
- }
-
break;
}
@@ -336,6 +340,27 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents
default:
break;
}
+
+ var isTextContent = content is MeaiTextContent;
+ var isAnnotationOnlyContentForCurrentMessage =
+ content.GetType() == typeof(AIContent) &&
+ IsSameAnnotationMessage(currentMessageId, previousMessageId);
+
+ // MEAI OpenAI sends streaming citations in a separate annotation-only AIContent after
+ // the text deltas. Accumulate them because AgentServer emits annotations only after
+ // output_text.done, and de-duplicate providers that report the same citation twice.
+ if ((isTextContent || isAnnotationOnlyContentForCurrentMessage)
+ && content.Annotations is { Count: > 0 }
+ && currentMessageBuilder is not null)
+ {
+ foreach (var sdkAnnotation in ConvertToSdkAnnotations(content.Annotations))
+ {
+ if (accumulatedAnnotations?.Any(existing => AreEquivalentAnnotations(existing, sdkAnnotation)) is not true)
+ {
+ (accumulatedAnnotations ??= []).Add(sdkAnnotation);
+ }
+ }
+ }
}
}
@@ -385,16 +410,93 @@ private static IEnumerable CloseCurrentMessage(
private static bool IsSameMessage(string? currentId, string? previousId) =>
currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId;
+ private static bool IsSameAnnotationMessage(string? currentId, string? previousId) =>
+ currentId is { Length: > 0 }
+ ? currentId == previousId
+ : previousId is not { Length: > 0 };
+
+ private static string? ResolveMessageId(AgentResponseUpdate update)
+ {
+ if (update.MessageId is { Length: > 0 })
+ {
+ return update.MessageId;
+ }
+
+ object? rawRepresentation = update.RawRepresentation is ChatResponseUpdate chatUpdate
+ ? chatUpdate.RawRepresentation
+ : update.RawRepresentation;
+
+ return rawRepresentation switch
+ {
+ OpenAIStreamingResponseOutputTextAnnotationAddedUpdate annotationAdded => annotationAdded.ItemId,
+ OpenAIStreamingResponseOutputTextDeltaUpdate textDelta => textDelta.ItemId,
+ OpenAIStreamingResponseOutputItemAddedUpdate { Item: OpenAIMessageResponseItem message } => message.Id,
+ OpenAIStreamingResponseOutputItemDoneUpdate { Item: OpenAIMessageResponseItem message } => message.Id,
+ _ => null,
+ };
+ }
+
+ private static bool AreEquivalentAnnotations(Annotation left, Annotation right) =>
+ (left, right) switch
+ {
+ (UrlCitationBody leftUrl, UrlCitationBody rightUrl) =>
+ leftUrl.Url == rightUrl.Url &&
+ leftUrl.StartIndex == rightUrl.StartIndex &&
+ leftUrl.EndIndex == rightUrl.EndIndex &&
+ leftUrl.Title == rightUrl.Title,
+ (FileCitationBody leftFile, FileCitationBody rightFile) =>
+ leftFile.FileId == rightFile.FileId &&
+ leftFile.Index == rightFile.Index &&
+ leftFile.Filename == rightFile.Filename,
+ (ContainerFileCitationBody leftContainerFile, ContainerFileCitationBody rightContainerFile) =>
+ leftContainerFile.ContainerId == rightContainerFile.ContainerId &&
+ leftContainerFile.FileId == rightContainerFile.FileId &&
+ leftContainerFile.StartIndex == rightContainerFile.StartIndex &&
+ leftContainerFile.EndIndex == rightContainerFile.EndIndex &&
+ leftContainerFile.Filename == rightContainerFile.Filename,
+ (FilePath leftFilePath, FilePath rightFilePath) =>
+ leftFilePath.FileId == rightFilePath.FileId &&
+ leftFilePath.Index == rightFilePath.Index,
+ _ => false,
+ };
+
///
/// Converts MEAI instances to Responses SDK objects.
- /// Only with a URL and at least one
- /// with explicit start/end indices is converted; all other shapes are skipped.
+ /// Only supported shapes are converted; all others are skipped.
///
private static IEnumerable ConvertToSdkAnnotations(IList annotations)
{
foreach (var ann in annotations)
{
- if (ann is not CitationAnnotation citation || citation.Url is null)
+ if (ann is not CitationAnnotation citation)
+ {
+ continue;
+ }
+
+ if (citation.RawRepresentation is OpenAIContainerFileCitationMessageAnnotation containerFileCitation)
+ {
+ yield return new ContainerFileCitationBody(
+ containerFileCitation.ContainerId,
+ containerFileCitation.FileId,
+ containerFileCitation.StartIndex,
+ containerFileCitation.EndIndex,
+ containerFileCitation.Filename);
+ continue;
+ }
+
+ if (citation.RawRepresentation is OpenAIFileCitationMessageAnnotation fileCitation)
+ {
+ yield return new FileCitationBody(fileCitation.FileId, fileCitation.Index, fileCitation.Filename);
+ continue;
+ }
+
+ if (citation.RawRepresentation is OpenAIFilePathMessageAnnotation filePath)
+ {
+ yield return new FilePath(filePath.FileId, filePath.Index);
+ continue;
+ }
+
+ if (citation.Url is null)
{
continue;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
index 72cbde5a6bd..8d3de412b6b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
@@ -33,7 +33,7 @@ internal sealed class A2AAgentHandler : IAgentHandler
/// Initializes a new instance of the class.
///
/// The hosted agent that provides the execution logic.
- /// Controls whether the agent runs in background mode.
+ /// Controls which A2A artifact the agent response is returned as.
public A2AAgentHandler(
AIHostAgent hostAgent,
AgentRunMode runMode)
@@ -80,20 +80,20 @@ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue
/// The queue the response events are written to.
///
/// to run the agent to completion before emitting a single completed task;
- /// to emit task updates as they are produced. Ignored when the server disallows
- /// background responses, because a message response is always aggregated.
+ /// to emit task updates as they are produced. Ignored when the server is configured
+ /// through to return a message, because a message response is always aggregated.
///
/// A to cancel the operation.
///
/// The response shape is decided by two independent inputs:
///
/// -
- /// Whether the server allows background responses. This is configured per agent registration, for example:
+ /// Which A2A artifact the server returns. This is configured per agent registration, for example:
///
/// builder.AddA2AServer(agent, (A2AServerRegistrationOptions options) =>
- /// options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
+ /// options.AgentRunMode = AgentRunMode.ReturnTask);
///
- /// Use AgentRunMode.DisallowBackground to always respond with a message instead of a task.
+ /// Use AgentRunMode.ReturnMessage to always respond with a message instead of a task.
///
/// -
/// Whether the client asked for an immediate response. In the A2A protocol this is the
@@ -104,17 +104,20 @@ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue
/// The resulting combinations are:
///
/// -
- /// Server allows background responses and ReturnImmediately = true: returns the initial task, then the
- /// rest of the updates piece by piece.
+ /// Server is configured through to return a task and ReturnImmediately = true:
+ /// returns the initial task, then the rest of the updates piece by piece.
///
/// -
- /// Server allows background responses and ReturnImmediately = false: returns a single completed task.
+ /// Server is configured through to return a task and ReturnImmediately = false:
+ /// returns a single completed task.
///
/// -
- /// Server disallows background responses and ReturnImmediately = true: returns a message.
+ /// Server is configured through to return a message and ReturnImmediately = true:
+ /// returns a message.
///
/// -
- /// Server disallows background responses and ReturnImmediately = false: returns a message.
+ /// Server is configured through to return a message and ReturnImmediately = false:
+ /// returns a message.
///
///
///
@@ -133,11 +136,11 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue
List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
- var options = CreateRunOptions(context);
-
- // Decide whether to run in background based on user preferences and agent capabilities
+ // Decide which A2A artifact to return based on the configured run mode.
var decisionContext = new A2ARunDecisionContext(context);
- var returnTask = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
+ var returnTask = await this._runMode.ShouldReturnTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false);
+
+ var options = CreateRunOptions(context);
var updates = this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken);
@@ -148,20 +151,20 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
if (aggregateTaskUpdates)
{
- // The server allows background responses, but the non-streaming client request has
+ // The server is configured through AgentRunMode to return a task, but the non-streaming client request has
// ReturnImmediately disabled, so collect all updates and return a completed task.
await AggregateTaskUpdatesAsync(updates, taskUpdater, eventQueue, cancellationToken).ConfigureAwait(false);
}
else
{
- // The server allows background responses and this is either a streaming request or a
+ // The server is configured through AgentRunMode to return a task and this is either a streaming request or a
// non-streaming request with ReturnImmediately enabled, so emit task updates as they arrive.
await StreamTaskUpdatesAsync(updates, taskUpdater, cancellationToken).ConfigureAwait(false);
}
}
else
{
- // The server disallows background responses, so return one aggregated message regardless
+ // The server is configured through AgentRunMode to return a message, so return one aggregated message regardless
// of the client request's ReturnImmediately value.
await StreamMessageUpdatesAsync(contextId, updates, eventQueue, cancellationToken).ConfigureAwait(false);
}
@@ -179,10 +182,7 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
List chatMessages = ExtractChatMessagesFromTaskHistory(context.Task);
- var decisionContext = new A2ARunDecisionContext(context);
- var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
-
- var options = CreateRunOptions(context, allowBackgroundResponses);
+ var options = CreateRunOptions(context);
AgentResponse response;
try
@@ -233,13 +233,8 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
/// MessageSendParams.metadata and MessageSendParams.configuration to the hosted agent.
///
/// The A2A request context of the incoming request.
- ///
- /// The value to assign to . Defaults to , which leaves it unset.
- ///
- ///
- /// The run options to invoke the agent with, or when there is nothing to forward.
- ///
- private static AgentRunOptions? CreateRunOptions(RequestContext context, bool? allowBackgroundResponses = null)
+ /// The run options to invoke the agent with.
+ private static AgentRunOptions CreateRunOptions(RequestContext context)
{
AdditionalPropertiesDictionary? additionalProperties = context.Metadata is { Count: > 0 }
? context.Metadata.ToAdditionalProperties()
@@ -252,14 +247,8 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue
(additionalProperties ??= [])[ConfigurationPropertyKey] = configuration;
}
- if (allowBackgroundResponses is null && additionalProperties is null)
- {
- return null;
- }
-
return new AgentRunOptions
{
- AllowBackgroundResponses = allowBackgroundResponses,
AdditionalProperties = additionalProperties
};
}
@@ -294,7 +283,8 @@ private static List ExtractChatMessagesFromTaskHistory(AgentTask? a
/// Emits a task and streams the agent updates into it as artifacts as they are produced.
///
///
- /// Handles the case where the server allows background responses and the response is delivered incrementally:
+ /// Handles the case where the server is configured through to return a task and the
+ /// response is delivered incrementally:
/// either a streaming (message/stream) request, or a non-streaming request with
/// ReturnImmediately = true. In the latter case the caller receives the initial task immediately and
/// obtains the remaining updates by polling the task.
@@ -342,7 +332,8 @@ private static async Task StreamTaskUpdatesAsync(IAsyncEnumerable
///
- /// Handles the case where the server allows background responses and a non-streaming client sent
+ /// Handles the case where the server is configured through to return a task and a
+ /// non-streaming client sent
/// ReturnImmediately = false, meaning it wants the final result in the response rather than a task
/// it has to poll. No task event is emitted until the agent stream finishes, because the server returns on the
/// first task event; emitting early would hand the caller an in-progress task instead of a completed one.
@@ -384,7 +375,8 @@ await eventQueue.AddArtifactAsync(
/// Consumes the agent updates and emits the aggregated result as a single message.
///
///
- /// Handles the case where the server disallows background responses, which applies regardless of the client's
+ /// Handles the case where the server is configured through to return a message, which
+ /// applies regardless of the client's
/// ReturnImmediately value: a message is not a long-running entity, so there is nothing to return early
/// or poll for and the full agent run is always aggregated into one message. An empty message is emitted when
/// the agent produces no messages.
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs
index 7bd30f9a7cd..2ef3f6be779 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs
@@ -16,7 +16,7 @@ public sealed class A2AServerRegistrationOptions
/// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
///
///
- /// When , defaults to .
+ /// When , defaults to .
///
public AgentRunMode? AgentRunMode { get; set; }
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
index 556a0d931a3..8e8240493c2 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
@@ -186,7 +186,7 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge
if (agentHandler is null)
{
var agentSessionStore = serviceProvider.GetKeyedService(agent.Name);
- var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
+ var runMode = options?.AgentRunMode ?? AgentRunMode.ReturnMessage;
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
if (agentSessionStore?.GetService() is null)
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
index 3abb90afb6d..95519b3255d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
@@ -10,7 +10,8 @@
namespace Microsoft.Agents.AI.Hosting.A2A;
///
-/// Specifies how the A2A hosting layer determines whether to run in background or not.
+/// Specifies which A2A protocol artifact the hosting layer returns for a run of an :
+/// an AgentMessage or an AgentTask.
///
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class AgentRunMode : IEquatable
@@ -20,48 +21,46 @@ public sealed class AgentRunMode : IEquatable
private const string DynamicValue = "dynamic";
private readonly string _value;
- private readonly Func>? _runInBackground;
+ private readonly Func>? _returnTask;
- private AgentRunMode(string value, Func>? runInBackground = null)
+ private AgentRunMode(string value, Func>? returnTask = null)
{
this._value = value;
- this._runInBackground = runInBackground;
+ this._returnTask = returnTask;
}
///
- /// Disallows the background responses from the agent. Is equivalent to configuring as false.
- /// In the A2A protocol terminology will make responses be returned as AgentMessage.
+ /// Returns the agent response as an AgentMessage. The updates produced by the agent are aggregated
+ /// into a single message.
///
- public static AgentRunMode DisallowBackground => new(MessageValue);
+ public static AgentRunMode ReturnMessage => new(MessageValue);
///
- /// Allows the background responses from the agent. Is equivalent to configuring as true.
- /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as AgentMessage otherwise.
+ /// Returns the agent response as an AgentTask, allowing the caller to track its lifecycle and to
+ /// receive the result incrementally.
///
- public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
+ public static AgentRunMode ReturnTask => new(TaskValue);
///
- /// The agent run mode is decided by the supplied delegate.
- /// The delegate receives an with the incoming
- /// message and returns a boolean specifying whether to run the agent in background mode.
- /// indicates that the agent should run in background mode and return an
- /// AgentTask if the agent supports background mode; otherwise, it returns an AgentMessage
- /// if the mode is not supported. indicates that the agent should run in
- /// non-background mode and return an AgentMessage.
+ /// Defers the choice between an AgentMessage and an AgentTask to the supplied
+ /// delegate, which is invoked for each new-message request. The delegate receives
+ /// an describing the incoming request and returns to
+ /// return an AgentTask, or to return an AgentMessage. Continuations of an
+ /// existing task remain task responses and do not invoke the delegate.
///
- ///
- /// An async delegate that decides whether the response should be wrapped in an AgentTask.
+ ///
+ /// An async delegate that decides whether a new-message response is returned as an AgentTask.
///
- public static AgentRunMode AllowBackgroundWhen(Func> runInBackground)
+ public static AgentRunMode ReturnTaskWhen(Func> returnTask)
{
- ArgumentNullException.ThrowIfNull(runInBackground);
- return new(DynamicValue, runInBackground);
+ ArgumentNullException.ThrowIfNull(returnTask);
+ return new(DynamicValue, returnTask);
}
///
/// Determines whether the agent response should be returned as an AgentTask.
///
- internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
+ internal ValueTask ShouldReturnTaskAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
{
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
{
@@ -74,9 +73,9 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex
}
// Dynamic: delegate to custom callback.
- if (this._runInBackground is not null)
+ if (this._returnTask is not null)
{
- return this._runInBackground(context, cancellationToken);
+ return this._returnTask(context, cancellationToken);
}
// No delegate provided â fall back to "message" behavior.
@@ -87,7 +86,7 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex
public bool Equals(AgentRunMode? other) =>
other is not null
&& string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase)
- && ReferenceEquals(this._runInBackground, other._runInBackground);
+ && ReferenceEquals(this._returnTask, other._returnTask);
///
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
@@ -95,7 +94,7 @@ other is not null
///
public override int GetHashCode() => HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(this._value),
- RuntimeHelpers.GetHashCode(this._runInBackground));
+ RuntimeHelpers.GetHashCode(this._returnTask));
///
public override string ToString() => this._value;
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs
index c23553f1855..da7aac682d6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs
@@ -8,10 +8,6 @@ namespace Microsoft.Agents.AI.Purview.Models.Jobs;
///
/// Class representing a job that refreshes the protection scopes cache in the background.
///
-///
-/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the
-/// foreground ProcessContent call.
-///
internal sealed class ScopeRetrievalJob : BackgroundJobBase
{
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs
index fab7c28d9ae..f49f1239a89 100644
--- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs
@@ -195,21 +195,22 @@ private async Task ProcessContentWithProtectionScopesAsy
ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
- if (cacheResponse != null)
+ if (cacheResponse == null)
{
- return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
- }
+ pcRequest.ProcessInline = true;
+ try
+ {
+ this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
+ }
+ catch (PurviewJobException)
+ {
+ // QueueJob logs admission failures. Scope refresh is best effort.
+ }
- try
- {
- this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest));
- }
- catch (PurviewJobException)
- {
- // QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent.
+ return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
}
- return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false);
+ return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false);
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs
index 9c50cff4a68..b04b4d2d7cf 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs
@@ -5,6 +5,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -37,6 +38,7 @@ namespace Microsoft.Agents.AI;
///
/// - file_access_write â Write a file with the given name and content.
/// - file_access_read â Read the content of a file by name.
+/// - file_access_read_lines â Read a range of lines from a file by line number.
/// - file_access_delete â Delete a file by name.
/// - file_access_ls â List the direct child files and subdirectories of a directory.
/// - file_access_grep â Recursively search file contents using a regular expression pattern.
@@ -44,12 +46,13 @@ namespace Microsoft.Agents.AI;
/// - file_access_replace_lines â Replace whole lines within a file.
///
/// When is set, only the read-only tools
-/// (file_access_read, file_access_ls, and file_access_grep) are exposed.
+/// (file_access_read, file_access_read_lines, file_access_ls, and
+/// file_access_grep) are exposed.
///
///
/// By default, all of these tools require approval: each is exposed as an .
/// Approval can be disabled per group via
-/// (read, ls, and grep) and
+/// (read, read_lines, ls, and grep) and
/// (write, delete, replace, and replace_lines).
///
///
@@ -57,8 +60,8 @@ namespace Microsoft.Agents.AI;
/// :
///
/// -
-/// â auto-approves only the read-only tools (read, ls,
-/// and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines).
+/// â auto-approves only the read-only tools (read, read_lines,
+/// ls, and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines).
///
/// -
/// â auto-approves every file access tool, including the tools that modify the store.
@@ -82,6 +85,9 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable
/// The name of the tool that reads a file.
public const string ReadFileToolName = "file_access_read";
+ /// The name of the tool that reads a range of lines from a file.
+ public const string ReadLinesToolName = "file_access_read_lines";
+
/// The name of the tool that deletes a file.
public const string DeleteFileToolName = "file_access_delete";
@@ -101,6 +107,7 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable
private static readonly HashSet s_readOnlyToolNames = new(StringComparer.Ordinal)
{
ReadFileToolName,
+ ReadLinesToolName,
LsToolName,
GrepToolName,
};
@@ -110,6 +117,7 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable
{
WriteToolName,
ReadFileToolName,
+ ReadLinesToolName,
DeleteFileToolName,
LsToolName,
GrepToolName,
@@ -129,6 +137,9 @@ These files persist beyond the current session and may be shared across sessions
or `file_access_grep` to search file contents recursively across the whole store.
- To make small edits to an existing file, prefer `file_access_replace` (substring replacement) or
`file_access_replace_lines` (whole-line replacement) over rewriting the whole file.
+ - To change part of a file, find the line numbers with `file_access_grep`, read the range around them
+ with `file_access_read_lines`, then edit with `file_access_replace_lines`. Reading the whole file
+ first is rarely necessary.
""";
private readonly AgentFileStore _fileStore;
@@ -161,7 +172,8 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o
///
/// Gets an auto-approval rule that approves the read-only file access tools
- /// (, , and ).
+ /// (, , ,
+ /// and ).
///
///
///
@@ -179,6 +191,7 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o
/// This rule approves calls to exactly the following tool names:
///
/// - (file_access_read)
+ /// - (file_access_read_lines)
/// - (file_access_ls)
/// - (file_access_grep)
///
@@ -213,6 +226,7 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o
///
/// - (file_access_write)
/// - (file_access_read)
+ /// - (file_access_read_lines)
/// - (file_access_delete)
/// - (file_access_ls)
/// - (file_access_grep)
@@ -292,7 +306,7 @@ private async Task WriteAsync(string fileName, string content, bool over
/// The name of the file to read.
/// A token to cancel the operation.
/// The file content or a not-found message.
- [Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")]
+ [Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found. To edit by 1-based line number afterwards, count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string path = StorePaths.NormalizeRelativePath(fileName);
@@ -300,6 +314,46 @@ private async Task ReadAsync(string fileName, CancellationToken cancella
return content ?? $"File '{fileName}' not found.";
}
+ ///
+ /// Read a range of lines from a file, each prefixed with its 1-based line number and a tab.
+ ///
+ /// The name of the file to read.
+ /// The 1-based line number to read from.
+ /// The 1-based line number to read through, inclusive. When , reads to the end of the file.
+ /// A token to cancel the operation.
+ /// The numbered lines, or a not-found message.
+ ///
+ /// The line numbers agree with the ones file_access_grep reports, because
+ /// must number by â
+ /// the split this method and file_access_replace_lines use. A store overriding it owns that
+ /// numbering; getting it wrong makes an edit land on a line the caller never saw.
+ ///
+ ///
+ /// Thrown when either bound is not positive, when precedes
+ /// , or when is past the last line.
+ ///
+ [Description("Read part of a file by 1-based inclusive line number; omit endLine to read to the end of the file, and an endLine past the last line is clamped. Each line is prefixed with its number and a tab; everything after that tab is verbatim, including the line's own terminator, so it can be reused as a file_access_replace_lines new_line. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r, and content ending in a terminator has no extra empty line after it.")]
+ private async Task ReadLinesAsync(string fileName, int startLine, int? endLine = null, CancellationToken cancellationToken = default)
+ {
+ string path = StorePaths.NormalizeRelativePath(fileName);
+ string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false);
+ if (content is null)
+ {
+ return $"File '{fileName}' not found.";
+ }
+
+ List lines = FileEditor.SliceLines(content, startLine, endLine);
+
+ // Each line keeps its terminator, so it doubles as the row separator.
+ var builder = new StringBuilder();
+ for (int i = 0; i < lines.Count; i++)
+ {
+ builder.Append(startLine + i).Append('\t').Append(lines[i]);
+ }
+
+ return builder.ToString();
+ }
+
///
/// Delete a file by name.
///
@@ -381,7 +435,7 @@ private async Task ReplaceAsync(string fileName, string oldString, strin
/// The list of 1-based line numbers and their literal replacement text.
/// A token to cancel the operation.
/// A confirmation message including the number of lines replaced, or a failure message.
- [Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
+ [Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task ReplaceLinesAsync(string fileName, List edits, CancellationToken cancellationToken = default)
{
await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -421,6 +475,7 @@ private async Task ReplaceLinesAsync(string fileName, List
- '**' matches across subdirectories, so use \"**/*.md\" to match markdown files at any depth, or \"reports/**\" to restrict the search to the 'reports' subtree.
Returns matching results whose file names are paths relative to the store root (usable with file_access_read), along with snippets and matching lines with line numbers.
+ Line numbers are 1-based and count lines terminated by \n, \r\n, or a lone \r, and content ending in a terminator has no extra empty line after it.
""")]
private async Task> GrepAsync(string regexPattern, string? globPattern = null, string? directory = null, CancellationToken cancellationToken = default)
{
@@ -464,6 +519,7 @@ private AITool[] CreateTools()
var tools = new List
{
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
+ WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadLinesAsync, new AIFunctionFactoryOptions { Name = ReadLinesToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval),
};
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs
index 8f8e406e486..c26b4781cec 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs
@@ -25,7 +25,8 @@ public sealed class FileAccessProviderOptions
///
///
/// When (the default), all tools are exposed. When ,
- /// only the read-only tools (file_access_read, file_access_ls, and file_access_grep)
+ /// only the read-only tools (file_access_read, file_access_read_lines, file_access_ls,
+ /// and file_access_grep)
/// are exposed; the tools that modify the store (file_access_write, file_access_delete,
/// file_access_replace, and file_access_replace_lines) are hidden.
///
@@ -33,8 +34,8 @@ public sealed class FileAccessProviderOptions
///
/// Gets or sets a value indicating whether approval is disabled for the read-only file access tools
- /// (, ,
- /// and ).
+ /// (, ,
+ /// , and ).
///
///
/// When (the default), these tools require approval before invocation.
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs
index 481089bd2c5..ee91be5cffd 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs
@@ -219,7 +219,7 @@ private async Task WriteAsync(string fileName, string content, string? d
/// The name of the file to read.
/// A token to cancel the operation.
/// The file content or a not-found message.
- [Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")]
+ [Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found. To edit by 1-based line number afterwards, count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task ReadAsync(string fileName, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
@@ -360,7 +360,7 @@ private async Task ReplaceAsync(string fileName, string oldString, strin
/// The list of 1-based line numbers and their literal replacement text.
/// A token to cancel the operation.
/// A confirmation message including the number of lines replaced, or a failure message.
- [Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")]
+ [Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")]
private async Task ReplaceLinesAsync(string fileName, List edits, CancellationToken cancellationToken = default)
{
string normalized = StorePaths.NormalizeRelativePath(fileName);
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs
index 4a7feee0517..e275d33a437 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs
@@ -1,11 +1,14 @@
īģŋ// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -92,7 +95,209 @@ public abstract class AgentFileStore
/// A list of search results. Each result's is the matching file's
/// path relative to .
///
- public abstract Task> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default);
+ ///
+ ///
+ /// Implementers overriding this method must report as a
+ /// 1-based coordinate into of the same content
+ /// returns, and should report verbatim, terminator included.
+ /// produces both correctly and is the recommended way to build results.
+ ///
+ ///
+ /// Numbering against anything else â a different split rule, or content this store does not serve
+ /// through â is a bug with a silent failure mode: the search looks correct,
+ /// and the damage appears later when a line edit applies to a line the caller never saw. Cover it
+ /// with a test that greps and then edits by the reported number.
+ ///
+ ///
+ public virtual async Task> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ // Compile with a match timeout to guard against catastrophic backtracking (ReDoS).
+ var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
+ IReadOnlyList names = await this.FindMatchingFilesAsync(directory, regexPattern, globPattern, recursive, cancellationToken).ConfigureAwait(false);
+ Matcher? matcher = globPattern is not null ? StorePaths.CreateGlobMatcher(globPattern) : null;
+ var results = new List();
+
+ foreach (string name in names)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Re-apply the caller's scope: FindMatchingFilesAsync is explicitly allowed to
+ // over-return, and must not be able to widen what the caller asked for.
+ if (!StorePaths.MatchesGlob(name, matcher) ||
+ (!recursive && name.IndexOf("/", StringComparison.Ordinal) >= 0))
+ {
+ continue;
+ }
+
+ string path = string.IsNullOrEmpty(directory) ? name : $"{directory.TrimEnd('/')}/{name}";
+ string? content = await this.ReadAsync(path, cancellationToken).ConfigureAwait(false);
+ if (content is null)
+ {
+ continue; // Deleted between enumeration and read.
+ }
+
+ FileSearchResult? result = ScanContent(name, content, regex);
+ if (result is not null)
+ {
+ results.Add(result);
+ }
+ }
+
+ return results;
+ }
+
+ ///
+ /// Gets the names of the files that may contain text that matches
+ /// and where file names may match
+ /// .
+ ///
+ ///
+ ///
+ /// This is the hook a store uses to narrow the search to the files worth reading. Semantics are
+ /// deliberately a superset: returning a file that turns out not to match is harmless,
+ /// because re-scans every candidate, while omitting one loses the match.
+ /// A backend with a native search index should override this and push
+ /// down to it, widening rather than guessing where the dialect
+ /// cannot express the pattern.
+ ///
+ ///
+ /// The default implementation has no index to narrow with, so it walks
+ /// and returns every file in scope, leaving
+ /// to read and scan all of them. Override this when the backing store
+ /// can answer either question more cheaply than that â a name index for
+ /// , a content or full-text index for â
+ /// and return the candidates it finds. That is the whole purpose of the hook: the store does the
+ /// narrowing it is good at, and the base keeps the scanning and the line numbering. Overriding
+ /// instead is also supported, but then line numbering is the store's
+ /// responsibility (see ), and nothing checks it at runtime.
+ ///
+ ///
+ /// The relative directory being searched. Use an empty string for the root.
+ ///
+ /// The pattern was called with, as a hint. It is matched
+ /// case-insensitively, so an index that cannot search that way must widen rather than narrow:
+ /// returning only case-exact candidates drops matches the caller would have got.
+ ///
+ ///
+ /// The optional glob, matched against each file's path relative to ,
+ /// also case-insensitively. The same rule applies â widen when the backend cannot reproduce it.
+ ///
+ /// When only direct children are in scope.
+ /// A token to cancel the operation.
+ /// File paths relative to , using forward slashes.
+ protected virtual async Task> FindMatchingFilesAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ _ = regexPattern; // No index to narrow with here; a backend with one overrides this.
+ var names = new List();
+ var pending = new Stack();
+ pending.Push(string.Empty);
+
+ while (pending.Count > 0)
+ {
+ // Checked here as well as passed down: a store whose ListChildrenAsync ignores the token
+ // would otherwise let a cancelled walk enumerate the whole hierarchy one listing at a time.
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string relativeDir = pending.Pop();
+ string target = string.IsNullOrEmpty(relativeDir)
+ ? directory
+ : (string.IsNullOrEmpty(directory) ? relativeDir : $"{directory.TrimEnd('/')}/{relativeDir}");
+
+ foreach (FileStoreEntry entry in await this.ListChildrenAsync(target, cancellationToken).ConfigureAwait(false))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string child = string.IsNullOrEmpty(relativeDir) ? entry.Name : $"{relativeDir}/{entry.Name}";
+ if (entry.Type == FileStoreEntry.Directory)
+ {
+ if (recursive)
+ {
+ pending.Push(child);
+ }
+ }
+ else
+ {
+ names.Add(child);
+ }
+ }
+ }
+
+ return names;
+ }
+
+ ///
+ /// Splits into the lines this SDK's line numbers address.
+ ///
+ ///
+ ///
+ /// This is the published definition of a line for the whole file-access surface: the
+ /// read_lines and replace_lines tools, and every
+ /// reported by , are coordinates in this list. Each line keeps its
+ /// terminator (\r\n, \n, or a lone \r), and the final line has none when the
+ /// content does not end with a newline.
+ ///
+ ///
+ /// A store that overrides must number its matches by this split,
+ /// otherwise grep and the line editor disagree and an edit lands on the wrong line. The rule is
+ /// per-SDK: it is not required to match the Python implementation, only to be consistent within
+ /// this one, because a line number never crosses runtimes.
+ ///
+ ///
+ /// The full text to split.
+ /// The lines, each with its terminator attached.
+ public static IReadOnlyList SplitLines(string content) => FileEditor.SplitLinesKeepEnds(Throw.IfNull(content));
+
+ ///
+ /// Finds every line of matching , numbered by
+ /// .
+ ///
+ ///
+ /// This is the numbering primitive uses, published so a store that
+ /// supplies its own can produce aligned results rather than re-deriving
+ /// them. Lines are reported verbatim, terminator included; the pattern is matched against the
+ /// line without its terminator, so an end-anchored pattern behaves the same on CRLF content.
+ ///
+ /// The name recorded on the result, relative to the searched directory.
+ /// The file's full text.
+ /// A compiled pattern, normally from the same source string passed to .
+ /// The match metadata, or when no line matches.
+ public static FileSearchResult? ScanContent(string fileName, string content, Regex regex)
+ {
+ _ = Throw.IfNull(fileName);
+ _ = Throw.IfNull(content);
+ _ = Throw.IfNull(regex);
+
+ IReadOnlyList lines = SplitLines(content);
+ var matchingLines = new List();
+ string? firstSnippet = null;
+ int lineStartOffset = 0;
+
+ for (int i = 0; i < lines.Count; i++)
+ {
+ // Match over the line's text only, without copying it out of the line.
+ Match match = regex.Match(lines[i], 0, FileEditor.LineContentLength(lines[i]));
+ if (match.Success)
+ {
+ matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i] });
+
+ // Build a context snippet around the first match (+/-50 chars).
+ if (firstSnippet is null)
+ {
+ int charIndex = lineStartOffset + match.Index;
+ int snippetStart = Math.Max(0, charIndex - 50);
+ int snippetEnd = Math.Min(content.Length, charIndex + match.Value.Length + 50);
+ firstSnippet = content.Substring(snippetStart, snippetEnd - snippetStart);
+ }
+ }
+
+ // Advance past this line; its terminator is already part of its length.
+ lineStartOffset += lines[i].Length;
+ }
+
+ return matchingLines.Count == 0
+ ? null
+ : new FileSearchResult { FileName = fileName, Snippet = firstSnippet!, MatchingLines = matchingLines };
+ }
///
/// Ensures a directory exists, creating it if necessary.
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs
index 32c3ff84783..eea214d1dde 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs
@@ -7,7 +7,8 @@ namespace Microsoft.Agents.AI;
///
/// Internal helpers shared by and
-/// for the replace and replace_lines tools.
+/// for the replace, replace_lines, and read_lines tools, and by the file stores
+/// for grep.
///
internal static class FileEditor
{
@@ -80,6 +81,20 @@ internal static string ApplyReplaceLines(string content, IReadOnlyList
+ /// Returns the 1-based inclusive [startLine, endLine] slice of ,
+ /// with each line's terminator kept attached. An past the last line is
+ /// clamped, and omitting it reads to the end of the content.
+ ///
+ ///
+ /// Thrown when either bound is not positive, when precedes
+ /// , or when is past the last line.
+ ///
+ internal static List SliceLines(string content, int startLine, int? endLine)
+ {
+ List lines = SplitLinesKeepEnds(content);
+ int total = lines.Count;
+
+ // These messages reach the model as the tool's failure text, so they name the arguments as the
+ // generated schema exposes them (startLine/endLine), not in snake_case.
+ if (startLine < 1)
+ {
+ throw new ArgumentException($"startLine must be a positive integer, got {startLine}.");
+ }
+
+ if (endLine is < 1)
+ {
+ throw new ArgumentException($"endLine must be a positive integer, got {endLine}.");
+ }
+
+ if (endLine < startLine)
+ {
+ throw new ArgumentException($"endLine ({endLine}) must not be less than startLine ({startLine}).");
+ }
+
+ if (startLine > total)
+ {
+ throw new ArgumentException($"startLine {startLine} is out of range (file has {total} lines).");
+ }
+
+ // Clamping end_line rather than failing keeps "read from here to the end" a single call.
+ int lastLine = endLine is null ? total : Math.Min(endLine.Value, total);
+ return lines.GetRange(startLine - 1, lastLine - startLine + 1);
+ }
+
+ ///
+ /// Returns without its trailing \r\n, \n or lone \r.
+ ///
+ internal static string TrimLineTerminator(string line) => line.Substring(0, LineContentLength(line));
+
+ ///
+ /// Returns the length of up to but excluding the \r\n, \n, or
+ /// lone \r that terminates it, so search patterns are matched against a line's text rather
+ /// than its line break.
+ ///
+ ///
+ /// Leaving any part of the terminator in range would make an end-anchored pattern such as
+ /// match$ fail on a CRLF or lone-CR line whose text is exactly match. This returns a
+ /// length rather than a trimmed string because the callers scan every line before knowing which ones
+ /// match, and copying each one would duplicate nearly the whole file on every search.
+ ///
+ internal static int LineContentLength(string line)
+ {
+ if (line.EndsWith("\r\n", StringComparison.Ordinal))
+ {
+ return line.Length - 2;
+ }
+
+ return line.EndsWith("\n", StringComparison.Ordinal) || line.EndsWith("\r", StringComparison.Ordinal)
+ ? line.Length - 1
+ : line.Length;
+ }
+
private static int CountOccurrences(string content, string value)
{
int count = 0;
@@ -109,7 +193,13 @@ private static int CountOccurrences(string content, string value)
/// Splits content into lines, keeping each line's trailing newline (\r\n, \n, or a lone
/// \r) attached. The final line has no terminator when the content does not end with a newline.
///
- private static List SplitLinesKeepEnds(string content)
+ ///
+ /// This is the single definition of a "line" for the line-edit tools, so the line numbers reported by
+ /// grep address the same lines that replace_lines edits. A store supplying its own
+ /// is expected to number by this split; nothing enforces that
+ /// at runtime, so an implementation that numbers differently edits the wrong line silently.
+ ///
+ internal static List SplitLinesKeepEnds(string content)
{
var lines = new List();
int start = 0;
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs
index 5100715bdf3..824d388ed75 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs
@@ -28,4 +28,15 @@ public sealed class FileLineEdit
[JsonPropertyName("new_line")]
[Description("Literal replacement text for the line, including any trailing newline you want to keep (the editor does not add one). Set to an empty string to delete the line entirely, including its line break.")]
public string NewLine { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the text the caller believes is currently on that line. When set, the edit is
+ /// rejected unless it matches, which catches an out-of-date line number or a file that changed
+ /// since it was read. This is the line's own text: a numbered read prefixes each line with its
+ /// number and a tab, and that prefix is not part of the line. The trailing line terminator is
+ /// ignored in the comparison.
+ ///
+ [JsonPropertyName("expected_line")]
+ [Description("Optional: the text you believe is currently on that line, as reported by grep. Give the line's own text only: a numbered read prefixes each line with its number and a tab, and that prefix is not part of the line. When supplied, the edit is rejected unless it matches, which catches an out-of-date line number or a file that changed since you looked. The trailing newline is ignored in the comparison.")]
+ public string? ExpectedLine { get; set; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs
index 0bf2d102d3a..a65cd7d217d 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs
@@ -19,8 +19,14 @@ public sealed class FileSearchMatch
public int LineNumber { get; set; }
///
- /// Gets or sets the content of the matching line.
+ /// Gets or sets the matching line, verbatim.
///
+ ///
+ /// Implementers should report the line exactly as it appears in the file, keeping its own terminator
+ /// (\r\n, \n, or a lone \r), except on a final line that the content does not
+ /// terminate. Together with addressing the same lines the line-edit tools use,
+ /// that makes the value reusable as a literal replacement line without re-reading the file.
+ ///
[JsonPropertyName("line")]
public string Line { get; set; } = string.Empty;
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs
index 8f3d171c94d..0cc2f6f9c17 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs
@@ -203,41 +203,12 @@ public override async Task> SearchAsync(
}
#endif
- // Search each line for regex matches, tracking line numbers and building a snippet.
- string[] lines = fileContent.Split('\n');
- var matchingLines = new List();
- string? firstSnippet = null;
- int lineStartOffset = 0;
-
- for (int i = 0; i < lines.Length; i++)
- {
- Match match = regex.Match(lines[i]);
- if (match.Success)
- {
- matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
-
- // Build a context snippet around the first match (Âą50 chars).
- if (firstSnippet is null)
- {
- int charIndex = lineStartOffset + match.Index;
- int snippetStart = Math.Max(0, charIndex - 50);
- int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50);
- firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart);
- }
- }
-
- // Advance the offset past this line (including the '\n' separator).
- lineStartOffset += lines[i].Length + 1;
- }
-
- if (matchingLines.Count > 0)
+ // Number the lines through the base class's published primitive, so this store
+ // and the line editor cannot drift apart.
+ FileSearchResult? result = ScanContent(relativeName, fileContent, regex);
+ if (result is not null)
{
- results.Add(new FileSearchResult
- {
- FileName = relativeName,
- Snippet = firstSnippet!,
- MatchingLines = matchingLines,
- });
+ results.Add(result);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs
index 62dfc020cb4..3c532320c74 100644
--- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs
@@ -141,42 +141,12 @@ public override Task> SearchAsync(string directo
continue;
}
- // Search each line for regex matches, tracking line numbers and building a snippet.
- string fileContent = kvp.Value;
- string[] lines = fileContent.Split('\n');
- var matchingLines = new List();
- string? firstSnippet = null;
- int lineStartOffset = 0;
-
- for (int i = 0; i < lines.Length; i++)
+ // Number the lines through the base class's published primitive, so this store
+ // and the line editor cannot drift apart.
+ FileSearchResult? result = ScanContent(relativeName, kvp.Value, regex);
+ if (result is not null)
{
- Match match = regex.Match(lines[i]);
- if (match.Success)
- {
- matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') });
-
- // Build a context snippet around the first match (Âą50 chars).
- if (firstSnippet is null)
- {
- int charIndex = lineStartOffset + match.Index;
- int snippetStart = Math.Max(0, charIndex - 50);
- int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50);
- firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart);
- }
- }
-
- // Advance the offset past this line (including the '\n' separator).
- lineStartOffset += lines[i].Length + 1;
- }
-
- if (matchingLines.Count > 0)
- {
- results.Add(new FileSearchResult
- {
- FileName = relativeName,
- Snippet = firstSnippet!,
- MatchingLines = matchingLines,
- });
+ results.Add(result);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt
index cc15ea45814..31d026ce287 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt
@@ -1,3 +1,11 @@
īģŋ#nullable enable
+*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string?
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void
+[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string!
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult?
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt
index cc15ea45814..31d026ce287 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt
@@ -1,3 +1,11 @@
īģŋ#nullable enable
+*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string?
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void
+[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string!
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult?
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt
index cc15ea45814..31d026ce287 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt
@@ -1,3 +1,11 @@
īģŋ#nullable enable
+*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string?
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void
+[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string!
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult?
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt
index cc15ea45814..31d026ce287 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt
@@ -1,3 +1,11 @@
īģŋ#nullable enable
+*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string?
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void
+[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string!
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult?
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
index cc15ea45814..31d026ce287 100644
--- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
+++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt
@@ -1,3 +1,11 @@
īģŋ#nullable enable
+*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan
[MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string?
+[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void
+[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string!
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult?
+[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
diff --git a/dotnet/src/Shared/IntegrationTests/TestSettings.cs b/dotnet/src/Shared/IntegrationTests/TestSettings.cs
index c2a7ab09730..ca830575fa4 100644
--- a/dotnet/src/Shared/IntegrationTests/TestSettings.cs
+++ b/dotnet/src/Shared/IntegrationTests/TestSettings.cs
@@ -22,6 +22,8 @@ internal static class TestSettings
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
// Azure AI Search (Foundry.Hosting integration tests, RAG scenario)
+ public const string AzureSearchConnectionId = "AZURE_SEARCH_CONNECTION_ID";
+ public const string AzureSearchConnectionName = "AZURE_SEARCH_CONNECTION_NAME";
public const string AzureSearchEndpoint = "AZURE_SEARCH_ENDPOINT";
public const string AzureSearchIndexName = "AZURE_SEARCH_INDEX_NAME";
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
index 087e9e81f43..0520bce3859 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs
@@ -3,6 +3,7 @@
using System.ComponentModel;
using Azure;
using Azure.AI.Projects;
+using Azure.AI.Projects.Agents;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
@@ -43,6 +44,8 @@
"custom-storage" => CreateCustomStorageAgent(projectClient, deployment),
"memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false),
"azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment),
+ "azure-search-tool-annotations" => CreateAzureSearchToolAnnotationsAgent(projectClient, deployment),
+ "web-search-annotations" => CreateWebSearchAnnotationsAgent(projectClient, deployment),
"session-files" => CreateSessionFilesAgent(projectClient, deployment),
"agent-skills" => CreateAgentSkillsAgent(projectClient, deployment),
"user-identity" => CreateUserIdentityAgent(projectClient, deployment),
@@ -77,14 +80,12 @@
options.SteerableConversations = scenario == "steerable-long-running";
});
-// toolbox-oauth-consent scenario: pre-register a Foundry toolbox whose tool source is fronted by a
-// per-user OAuth connection. IT_TOOLBOX_NAME names that toolbox (the fixture sets it). With the
-// startup-deferral fix the container stays routable even though the toolbox cannot enumerate without
-// a consented user, and the first user request surfaces an oauth_consent_request.
-var consentToolboxName = Environment.GetEnvironmentVariable("IT_TOOLBOX_NAME");
-if (!string.IsNullOrEmpty(consentToolboxName))
+// Scenarios that consume a project toolbox set IT_TOOLBOX_NAME through their fixture.
+// The hosting bridge resolves the toolbox through its MCP endpoint and adds its tools to every request.
+var toolboxName = Environment.GetEnvironmentVariable("IT_TOOLBOX_NAME");
+if (!string.IsNullOrEmpty(toolboxName))
{
- builder.Services.AddFoundryToolboxes(credential, consentToolboxName);
+ builder.Services.AddFoundryToolboxes(credential, toolboxName);
}
var app = builder.Build();
@@ -207,6 +208,59 @@ static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployme
});
}
+static AIAgent CreateWebSearchAnnotationsAgent(AIProjectClient client, string deployment) =>
+ client.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "web-search-annotations-agent",
+ Description = "Hosted web search annotation test agent.",
+ ChatOptions = new ChatOptions
+ {
+ ModelId = deployment,
+ Instructions = """
+ Answer with current information from the web search results.
+ Include citations for the sources used in the answer.
+ """,
+ Tools = [new HostedWebSearchTool()],
+ ToolMode = ChatToolMode.RequireAny,
+ },
+ });
+
+static AIAgent CreateAzureSearchToolAnnotationsAgent(AIProjectClient client, string deployment)
+{
+ var connectionId = Environment.GetEnvironmentVariable("AZURE_SEARCH_CONNECTION_ID")
+ ?? throw new InvalidOperationException(
+ "AZURE_SEARCH_CONNECTION_ID is not set for IT_SCENARIO=azure-search-tool-annotations.");
+ var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME")
+ ?? throw new InvalidOperationException(
+ "AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-tool-annotations.");
+ var searchTool = FoundryAITool.CreateAzureAISearchTool(new AzureAISearchToolOptions(
+ [
+ new AzureAISearchToolIndex
+ {
+ ProjectConnectionId = connectionId,
+ IndexName = indexName,
+ QueryType = AzureAISearchQueryType.Simple,
+ TopK = 3,
+ }
+ ]));
+
+ return client.AsAIAgent(new ChatClientAgentOptions
+ {
+ Name = "azure-search-tool-annotations-agent",
+ Description = "Azure AI Search hosted tool annotation test agent.",
+ ChatOptions = new ChatOptions
+ {
+ ModelId = deployment,
+ Instructions = """
+ Answer only from the Azure AI Search results.
+ Include citations for the sources used in the answer.
+ """,
+ Tools = [searchTool],
+ ToolMode = ChatToolMode.RequireAny,
+ },
+ });
+}
+
static Func>>
CreateAzureSearchAdapter(SearchClient client, int top = 3) =>
async (query, cancellationToken) =>
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs
new file mode 100644
index 00000000000..ada8866dc8a
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs
@@ -0,0 +1,110 @@
+īģŋ// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Foundry.Hosting.IntegrationTests.Fixtures;
+using OpenAI.Responses;
+
+#pragma warning disable OPENAI001 // Experimental Responses API surfaces
+
+namespace Foundry.Hosting.IntegrationTests;
+
+///
+/// Verifies that citations produced by the Foundry Azure AI Search hosted tool survive the nested
+/// model call and are returned by the hosted Responses API.
+///
+[Trait("Category", "FoundryHostedAgents")]
+public sealed class AzureSearchToolAnnotationsHostedAgentTests(
+ AzureSearchToolAnnotationsHostedAgentFixture fixture)
+ : IClassFixture
+{
+ private static readonly TimeSpan s_timeout = TimeSpan.FromMinutes(3);
+ private readonly AzureSearchToolAnnotationsHostedAgentFixture _fixture = fixture;
+
+ [Fact]
+ public async Task ResponsesApi_Streaming_EmitsUrlCitationAnnotationsAsync()
+ {
+ // Arrange
+ ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
+ CreateResponseOptions options = CreateRequest();
+ using CancellationTokenSource timeout = new(s_timeout);
+ List annotations = [];
+ StreamingResponseCompletedUpdate? completed = null;
+
+ // Act
+ await foreach (StreamingResponseUpdate update in responses
+ .CreateResponseStreamingAsync(options, timeout.Token)
+ .WithCancellation(timeout.Token))
+ {
+ switch (update)
+ {
+ case StreamingResponseOutputTextAnnotationAddedUpdate
+ {
+ Annotation: UriCitationMessageAnnotation annotation
+ }:
+ annotations.Add(annotation);
+ break;
+
+ case StreamingResponseCompletedUpdate completedUpdate:
+ completed = completedUpdate;
+ break;
+
+ case StreamingResponseFailedUpdate failed:
+ throw new InvalidOperationException(
+ $"Hosted Azure AI Search response failed: {failed.Response.Error?.Message}");
+ }
+ }
+
+ // Assert
+ Assert.NotNull(completed);
+ Assert.Contains(annotations, IsValidUrlCitation);
+ Assert.Contains(GetFinalAnnotations(completed.Response), IsValidUrlCitation);
+ Assert.Contains("TR-CANARY-7821", completed.Response.GetOutputText(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ResponsesApi_NonStreaming_ReturnsUrlCitationAnnotationsAsync()
+ {
+ // Arrange
+ ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
+ CreateResponseOptions options = CreateRequest();
+ using CancellationTokenSource timeout = new(s_timeout);
+
+ // Act
+ ResponseResult response = (await responses.CreateResponseAsync(options, timeout.Token)).Value;
+
+ // Assert
+ Assert.True(
+ response.Status == ResponseStatus.Completed,
+ $"Hosted Azure AI Search response failed: {response.Error?.Message}");
+ Assert.Contains("TR-CANARY-7821", response.GetOutputText(), StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(GetFinalAnnotations(response), IsValidUrlCitation);
+ }
+
+ private static CreateResponseOptions CreateRequest()
+ {
+ CreateResponseOptions options = new()
+ {
+ StoredOutputEnabled = false,
+ };
+ options.InputItems.Add(ResponseItem.CreateUserMessageItem(
+ "What item code do I get with my return? Use Azure AI Search and cite the source."));
+ return options;
+ }
+
+ private static IEnumerable GetFinalAnnotations(ResponseResult response) =>
+ response.OutputItems
+ .OfType()
+ .SelectMany(message => message.Content)
+ .SelectMany(part => part.OutputTextAnnotations)
+ .OfType();
+
+ private static bool IsValidUrlCitation(UriCitationMessageAnnotation annotation) =>
+ annotation.Uri.IsAbsoluteUri &&
+ !string.IsNullOrWhiteSpace(annotation.Title) &&
+ annotation.StartIndex >= 0 &&
+ annotation.EndIndex >= annotation.StartIndex;
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs
new file mode 100644
index 00000000000..008a5b6dc68
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs
@@ -0,0 +1,29 @@
+īģŋ// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using AgentConformance.IntegrationTests.Support;
+using Shared.IntegrationTests;
+
+namespace Foundry.Hosting.IntegrationTests.Fixtures;
+
+///
+/// Provisions a hosted agent that uses the Foundry Azure AI Search hosted tool and exposes its
+/// citations through the hosted Responses API.
+///
+public sealed class AzureSearchToolAnnotationsHostedAgentFixture : HostedAgentFixture
+{
+ private const string DefaultConnectionName = "azure-ai-search-contoso";
+
+ protected override string ScenarioName => "azure-search-tool-annotations";
+
+ protected override void ConfigureEnvironment(IDictionary environment)
+ {
+ var connectionName =
+ TestConfiguration.GetValue(TestSettings.AzureSearchConnectionName) ??
+ DefaultConnectionName;
+ environment[TestSettings.AzureSearchConnectionId] =
+ this.ProjectClient.Connections.GetConnection(connectionName).Value.Id;
+ environment[TestSettings.AzureSearchIndexName] =
+ TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName);
+ }
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs
new file mode 100644
index 00000000000..80ed518b035
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs
@@ -0,0 +1,12 @@
+īģŋ// Copyright (c) Microsoft. All rights reserved.
+
+namespace Foundry.Hosting.IntegrationTests.Fixtures;
+
+///
+/// Provisions a hosted agent that uses
+/// and exposes its output through the hosted Responses API.
+///
+public sealed class WebSearchAnnotationsHostedAgentFixture : HostedAgentFixture
+{
+ protected override string ScenarioName => "web-search-annotations";
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
index 99e0cc43c12..b29c8f85c46 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
@@ -72,7 +72,8 @@ The container scenario injects `USER-ID:` via
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. |
| `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. |
| `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. |
-| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. |
+| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index used by both Search scenarios. |
+| `AZURE_SEARCH_CONNECTION_NAME` | Foundry project connection | Optional connection name used by the `azure-search-tool-annotations` scenario. Defaults to `azure-ai-search-contoso`. |
## One-time bootstrap (per Foundry project)
@@ -97,7 +98,13 @@ running the tests.
The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what
every hosted agent needs to receive inbound inference traffic. Scenarios that read from
external data services need an additional grant on that service to the agent's managed
-identity. Today only the `azure-search-rag` scenario falls into this category.
+identity. Both Search scenarios need data-plane access, but they use different identities:
+
+- `azure-search-rag` calls `SearchClient` inside the container, so the hosted agent's managed
+ identity needs `Search Index Data Reader`.
+- `azure-search-tool-annotations` sends a Foundry Azure AI Search hosted tool through a
+ `ProjectManagedIdentity` connection, so the Foundry account's managed identity needs the roles
+ listed below.
For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader`
on the Azure AI Search service to the agent's managed identity:
@@ -120,6 +127,35 @@ az role assignment create `
Wait ~3 minutes after the grant for RBAC propagation before running the tests.
+The `azure-search-tool-annotations` fixture resolves the pre-provisioned connection by name and
+passes its full resource ID plus the index name to the container. The container builds the Azure AI
+Search hosted tool descriptor sent with the model request. Creating this descriptor does not
+provision a project resource. The connection must use `ProjectManagedIdentity`. Grant the Foundry
+account's system-assigned managed identity the roles required by the hosted tool on the Search service:
+
+```powershell
+az role assignment create `
+ --assignee-object-id "" `
+ --assignee-principal-type ServicePrincipal `
+ --role "Search Index Data Contributor" `
+ --scope "/subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/"
+
+az role assignment create `
+ --assignee-object-id "" `
+ --assignee-principal-type ServicePrincipal `
+ --role "Search Service Contributor" `
+ --scope "/subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/"
+```
+
+The integration environment must provision these roles and the connection before running
+`AzureSearchToolAnnotationsHostedAgentTests`.
+
+Projects may also retain a dedicated `ai-search-toolbox` containing the same Azure AI Search tool,
+connection, and index for toolbox integration tests. Keep that toolbox separate from the
+`AzureSearchToolAnnotationsHostedAgentTests`: toolbox calls surface the search result as tool output,
+while these tests specifically verify provider-generated `response.output_text.annotation.added`
+events from the hosted tool descriptor.
+
If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra
auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first:
@@ -129,8 +165,10 @@ az search service update -g -n --auth-options aadOrApiKey
### Azure AI Search index prerequisite (one time, out of band)
-The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already
-exists with the schema and Contoso Outdoors content the test asserts against. See
+Both Search scenarios assume the index pointed at by `AZURE_SEARCH_INDEX_NAME` already exists
+with the schema and Contoso Outdoors content the tests assert against. The index must include
+retrievable source name and source URL fields so the hosted tool can return `url_citation`
+annotations. See
`dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for
the schema and copy-pasteable provisioning snippet. Provisioning the index from your user
identity needs `Search Index Data Contributor` on the search service scope. The search service
@@ -217,7 +255,7 @@ container, the test fixture, or their tooling changed:
| `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` |
| `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) |
| `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) |
-| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) |
+| `FOUNDRY_HOSTED_AGENT_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` |
Like all integration tests in this workflow, the steps run only on `push` and merge-queue
events, never on plain `pull_request`. The path-filter list lives in the `paths-filter`
@@ -249,6 +287,8 @@ human-only operation; CI only adds and deletes versions under existing agents.
| `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). |
| `MemoryHostedAgentFixture` | `memory` | `it-memory` | `FoundryMemoryProvider` (scoped via `HostedSessionContext`) running inside the hosted agent recalls user preferences across multiple turns; the memory store name is randomised per fixture (`IT_MEMORY_STORE_ID`). |
| `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. |
+| `AzureSearchToolAnnotationsHostedAgentFixture` | `azure-search-tool-annotations` | `it-azure-search-tool-annotations` | Uses the Azure AI Search hosted tool with a pre-provisioned connection and verifies URL citation annotations through streaming and non-streaming Responses API calls. |
+| `WebSearchAnnotationsHostedAgentFixture` | `web-search-annotations` | `it-web-search-annotations` | Calls `HostedWebSearchTool` and verifies URL citation annotations through streaming and non-streaming Responses API calls. |
| `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. |
| `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. |
| `ResilientWorkflowHostedAgentFixture` | `resilient-workflow` | `it-resilient-workflow` | Stored background workflow remains active without client traffic, completes after an intentional container process crash, and replays a complete 20-item countdown without a sequence cursor. |
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs
new file mode 100644
index 00000000000..5bb71c3f92d
--- /dev/null
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs
@@ -0,0 +1,108 @@
+īģŋ// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Foundry.Hosting.IntegrationTests.Fixtures;
+using OpenAI.Responses;
+
+#pragma warning disable OPENAI001 // Experimental Responses API surfaces
+
+namespace Foundry.Hosting.IntegrationTests;
+
+///
+/// Verifies that citations produced by
+/// survive the nested model call and are returned by the hosted Responses API.
+///
+[Trait("Category", "FoundryHostedAgents")]
+public sealed class WebSearchAnnotationsHostedAgentTests(
+ WebSearchAnnotationsHostedAgentFixture fixture)
+ : IClassFixture
+{
+ private static readonly TimeSpan s_timeout = TimeSpan.FromMinutes(3);
+ private readonly WebSearchAnnotationsHostedAgentFixture _fixture = fixture;
+
+ [Fact]
+ public async Task ResponsesApi_Streaming_EmitsUrlCitationAnnotationsAsync()
+ {
+ // Arrange
+ ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
+ CreateResponseOptions options = CreateRequest();
+ using CancellationTokenSource timeout = new(s_timeout);
+ List annotations = [];
+ StreamingResponseCompletedUpdate? completed = null;
+
+ // Act
+ await foreach (StreamingResponseUpdate update in responses
+ .CreateResponseStreamingAsync(options, timeout.Token)
+ .WithCancellation(timeout.Token))
+ {
+ switch (update)
+ {
+ case StreamingResponseOutputTextAnnotationAddedUpdate
+ {
+ Annotation: UriCitationMessageAnnotation annotation
+ }:
+ annotations.Add(annotation);
+ break;
+
+ case StreamingResponseCompletedUpdate completedUpdate:
+ completed = completedUpdate;
+ break;
+
+ case StreamingResponseFailedUpdate failed:
+ throw new InvalidOperationException(
+ $"Hosted web search response failed: {failed.Response.Error?.Message}");
+ }
+ }
+
+ // Assert
+ Assert.NotNull(completed);
+ Assert.Contains(annotations, IsValidUrlCitation);
+ Assert.Contains(GetFinalAnnotations(completed.Response), IsValidUrlCitation);
+ }
+
+ [Fact]
+ public async Task ResponsesApi_NonStreaming_ReturnsUrlCitationAnnotationsAsync()
+ {
+ // Arrange
+ ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient();
+ CreateResponseOptions options = CreateRequest();
+ using CancellationTokenSource timeout = new(s_timeout);
+
+ // Act
+ ResponseResult response = (await responses.CreateResponseAsync(options, timeout.Token)).Value;
+
+ // Assert
+ Assert.Equal(ResponseStatus.Completed, response.Status);
+ Assert.False(string.IsNullOrWhiteSpace(response.GetOutputText()));
+ Assert.Contains(GetFinalAnnotations(response), IsValidUrlCitation);
+ }
+
+ private static CreateResponseOptions CreateRequest()
+ {
+ CreateResponseOptions options = new()
+ {
+ StoredOutputEnabled = false,
+ };
+ options.InputItems.Add(ResponseItem.CreateUserMessageItem(
+ "Search the web for the official Microsoft .NET support policy. " +
+ "Report the current support end date for .NET 10 and cite the official source."));
+ return options;
+ }
+
+ private static IEnumerable GetFinalAnnotations(ResponseResult response) =>
+ response.OutputItems
+ .OfType()
+ .SelectMany(message => message.Content)
+ .SelectMany(part => part.OutputTextAnnotations)
+ .OfType();
+
+ private static bool IsValidUrlCitation(UriCitationMessageAnnotation annotation) =>
+ annotation.Uri.IsAbsoluteUri &&
+ !string.IsNullOrWhiteSpace(annotation.Title) &&
+ annotation.StartIndex >= 0 &&
+ annotation.EndIndex >= annotation.StartIndex;
+}
diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
index 9aa0472bd57..384c4361ea3 100644
--- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
+++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1
@@ -50,6 +50,8 @@ $Scenarios = @(
'custom-storage',
'memory',
'azure-search-rag',
+ 'azure-search-tool-annotations',
+ 'web-search-annotations',
'session-files',
'agent-skills',
'user-identity',
diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs
index 6ff7b3cd8d5..45d027fc261 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs
@@ -511,6 +511,8 @@ public TestBetaService(IAnthropicClient client)
public global::Anthropic.Services.Beta.ITunnelService Tunnels => throw new NotImplementedException();
+ public global::Anthropic.Services.Beta.IOrganizationService Organization => throw new NotImplementedException();
+
public IBetaService WithOptions(Func modifier)
{
throw new NotImplementedException();
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs
index 2b7ae5b0061..509a24a6ab9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs
@@ -1366,6 +1366,50 @@ await DrainEventsAsync(handler.CreateAsync(
Assert.Equal("set by the container", raw.EndUserId);
}
+ [Fact]
+ public async Task CreateAsync_ChatClientAnnotationOnlyUpdate_EmitsCitationAsync()
+ {
+ // Arrange
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var client = new Mock();
+ client.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(() => ToAsyncEnumerableUpdatesAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "resp_msg_1" },
+ new ChatResponseUpdate(
+ ChatRole.Assistant,
+ [new AIContent { Annotations = [annotation] }])
+ {
+ MessageId = "resp_msg_1"
+ }));
+
+ var agent = new ChatClientAgent(client.Object);
+ var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore());
+
+ // Act
+ var events = new List();
+ await foreach (var evt in handler.CreateAsync(
+ NewConversationRequest("conv-citation", "a question", store: true),
+ NewContextServing("resp_" + new string('c', 46), []),
+ CancellationToken.None))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ var annotationEvent = Assert.Single(events.OfType());
+ var citation = Assert.IsType(annotationEvent.Annotation);
+ Assert.Equal(new Uri("https://example.com/doc"), citation.Url);
+ Assert.Equal("Example Document", citation.Title);
+ }
+
private static CreateResponse NewConversationRequest(string conversationId, string text, bool store)
{
var request = new CreateResponse { Model = "test", Store = store };
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
index f9e81d5a3ed..41c9906b830 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj
@@ -13,6 +13,7 @@
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs
index d385a763edd..db036cfa73f 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs
@@ -10,7 +10,16 @@
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Moq;
+using ContainerFileCitationMessageAnnotation = OpenAI.Responses.ContainerFileCitationMessageAnnotation;
+using FileCitationMessageAnnotation = OpenAI.Responses.FileCitationMessageAnnotation;
+using FilePathMessageAnnotation = OpenAI.Responses.FilePathMessageAnnotation;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
+using OpenAIResponseItem = OpenAI.Responses.ResponseItem;
+using OpenAIStreamingResponseOutputItemDoneUpdate = OpenAI.Responses.StreamingResponseOutputItemDoneUpdate;
+using OpenAIStreamingResponseOutputTextAnnotationAddedUpdate = OpenAI.Responses.StreamingResponseOutputTextAnnotationAddedUpdate;
+using OpenAIStreamingResponseOutputTextDeltaUpdate = OpenAI.Responses.StreamingResponseOutputTextDeltaUpdate;
+
+#pragma warning disable OPENAI001 // Experimental Responses API surfaces
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
@@ -1404,6 +1413,498 @@ public async Task ConvertUpdatesToEventsAsync_TextWithUrlCitationAnnotation_Emit
Assert.IsType(events[^1]);
}
+ /// An annotation-only update following text for the same message emits a url_citation event.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyContentAfterText_EmitsAnnotationEventAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("Hello")]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ var annotationEvent = Assert.Single(events.OfType());
+ var urlCitation = Assert.IsType(annotationEvent.Annotation);
+ Assert.Equal(new Uri("https://example.com/doc"), urlCitation.Url);
+ Assert.Equal("Example Document", urlCitation.Title);
+ Assert.Equal(0L, urlCitation.StartIndex);
+ Assert.Equal(5L, urlCitation.EndIndex);
+
+ var contentPartDone = Assert.Single(events.OfType());
+ var donePart = Assert.IsType(contentPartDone.Part);
+ Assert.IsType(Assert.Single(donePart.Annotations));
+
+ var outputItemDone = Assert.Single(events.OfType());
+ var doneMessage = Assert.IsType(outputItemDone.Item);
+ var doneText = Assert.IsType(Assert.Single(doneMessage.Content));
+ Assert.IsType(Assert.Single(doneText.Annotations));
+ }
+
+ /// The same citation attached to text and a later annotation-only update is emitted once.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_DuplicateCitationAcrossContentUpdates_EmitsOnceAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("Hello") { Annotations = [annotation] }]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events.OfType());
+ }
+
+ /// An annotation-only update for a different message is not attached to the open message.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationForDifferentMessage_IsSkippedAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("Hello")]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_2",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Empty(events.OfType());
+ }
+
+ /// An annotation-only update without a message ID is not attached to the open message.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationWithoutMessageId_IsSkippedAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("Hello")]
+ },
+ new AgentResponseUpdate
+ {
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Empty(events.OfType());
+ }
+
+ /// Generic updates without message IDs can attach annotations to the only open message.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_TextAndAnnotationWithoutMessageIds_EmitsAnnotationAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ Contents = [new MeaiTextContent("Hello")]
+ },
+ new AgentResponseUpdate
+ {
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events.OfType());
+ }
+
+ /// OpenAI item IDs recover correlation when flattened message IDs are absent.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_OpenAIRawItemIds_EmitsAnnotationAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var completedMessage = OpenAIResponseItem.CreateAssistantMessageItem("Hello");
+ completedMessage.Id = "msg_raw";
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ Contents = [new MeaiTextContent("Hello")],
+ RawRepresentation = new ChatResponseUpdate
+ {
+ RawRepresentation = new OpenAIStreamingResponseOutputTextDeltaUpdate
+ {
+ ItemId = "msg_raw"
+ }
+ }
+ },
+ new AgentResponseUpdate
+ {
+ Contents = [new AIContent { Annotations = [annotation] }],
+ RawRepresentation = new ChatResponseUpdate
+ {
+ RawRepresentation = new OpenAIStreamingResponseOutputItemDoneUpdate
+ {
+ Item = completedMessage
+ }
+ }
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events.OfType());
+ }
+
+ /// The OpenAI annotation event item ID correlates annotation content when the flattened ID is absent.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_OpenAIRawAnnotationItemId_EmitsAnnotationAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_raw",
+ Contents = [new MeaiTextContent("Hello")]
+ },
+ new AgentResponseUpdate
+ {
+ Contents = [new AIContent { Annotations = [annotation] }],
+ RawRepresentation = new ChatResponseUpdate
+ {
+ RawRepresentation = new OpenAIStreamingResponseOutputTextAnnotationAddedUpdate
+ {
+ ItemId = "msg_raw"
+ }
+ }
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events.OfType());
+ }
+
+ /// An annotation on non-text content is not attached to the open text message.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationOnDataContent_IsSkippedAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/image"),
+ Title = "Image source",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("Hello")]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents =
+ [
+ new DataContent("data:image/png;base64,aWNv", "image/png")
+ {
+ Annotations = [annotation]
+ }
+ ]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Empty(events.OfType());
+ }
+
+ /// An annotation-only update without an open text message does not create a message.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationWithoutOpenMessage_IsSkippedAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ };
+ var update = new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync([update]), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ Assert.Single(events);
+ Assert.IsType(events[0]);
+ }
+
+ /// An annotation-only file citation is emitted with its file metadata.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyFileCitation_EmitsAnnotationEventAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ FileId = "file_123",
+ Title = "report.pdf",
+ RawRepresentation = new FileCitationMessageAnnotation("file_123", 2, "report.pdf")
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("See the report")]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ var annotationEvent = Assert.Single(events.OfType());
+ var fileCitation = Assert.IsType(annotationEvent.Annotation);
+ Assert.Equal("file_123", fileCitation.FileId);
+ Assert.Equal(2L, fileCitation.Index);
+ Assert.Equal("report.pdf", fileCitation.Filename);
+ }
+
+ /// An annotation-only file path is emitted with its file metadata.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyFilePath_EmitsAnnotationEventAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ FileId = "file_123",
+ RawRepresentation = new FilePathMessageAnnotation("file_123", 3)
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("Download the file")]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ var annotationEvent = Assert.Single(events.OfType());
+ var filePath = Assert.IsType(annotationEvent.Annotation);
+ Assert.Equal("file_123", filePath.FileId);
+ Assert.Equal(3L, filePath.Index);
+ }
+
+ /// An annotation-only container file citation is emitted with its container and span metadata.
+ [Fact]
+ public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyContainerFileCitation_EmitsAnnotationEventAsync()
+ {
+ // Arrange
+ var (stream, _) = CreateTestStream();
+ var annotation = new CitationAnnotation
+ {
+ FileId = "file_123",
+ Title = "chart.png",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 4, EndIndex = 9 }],
+ RawRepresentation = new ContainerFileCitationMessageAnnotation(
+ "container_123",
+ "file_123",
+ 4,
+ 9,
+ "chart.png")
+ };
+ var updates = new[]
+ {
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new MeaiTextContent("See chart")]
+ },
+ new AgentResponseUpdate
+ {
+ MessageId = "msg_1",
+ Contents = [new AIContent { Annotations = [annotation] }]
+ },
+ };
+
+ // Act
+ var events = new List();
+ await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream))
+ {
+ events.Add(evt);
+ }
+
+ // Assert
+ var annotationEvent = Assert.Single(events.OfType());
+ var containerCitation = Assert.IsType(annotationEvent.Annotation);
+ Assert.Equal("container_123", containerCitation.ContainerId);
+ Assert.Equal("file_123", containerCitation.FileId);
+ Assert.Equal(4L, containerCitation.StartIndex);
+ Assert.Equal(9L, containerCitation.EndIndex);
+ Assert.Equal("chart.png", containerCitation.Filename);
+ }
+
/// The content_part.done and output_item.done payloads carry the url_citation metadata, guarding against the empty-annotations regression where only the annotation.added event fires.
[Fact]
public async Task ConvertUpdatesToEventsAsync_TextWithUrlCitationAnnotation_DoneEventsCarryAnnotationMetadataAsync()
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs
index 855e7b861ff..027572f3cea 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs
@@ -2,13 +2,18 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Net.ServerSentEvents;
using System.Text;
+using System.Text.Json;
+using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
@@ -19,6 +24,8 @@
using Moq;
using OpenAI.Responses;
+#pragma warning disable OPENAI001 // Experimental Responses API surfaces
+
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
public class ServiceCollectionExtensionsTests
@@ -324,6 +331,215 @@ public async Task MapFoundryResponses_HostedCreateWithoutCallId_ReturnsUnsupport
Assert.Contains("2.0.0", body, StringComparison.Ordinal);
}
+ [Fact]
+ public async Task MapFoundryResponses_StreamTrue_EmitsAllAnnotationKindsAsync()
+ {
+ // Arrange and Act
+ var (statusCode, mediaType, body) = await InvokeResponsesEndpointAsync(stream: true);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, statusCode);
+ Assert.Equal("text/event-stream", mediaType);
+
+ var events = await ParseSseEventsAsync(body);
+ var annotationEvents = events
+ .Where(e => e.GetProperty("type").GetString() == "response.output_text.annotation.added")
+ .Select(e => e.GetProperty("annotation"))
+ .ToArray();
+ AssertAnnotations(annotationEvents);
+
+ var contentPartDone = Assert.Single(events, e => e.GetProperty("type").GetString() == "response.content_part.done");
+ AssertAnnotations(contentPartDone.GetProperty("part"));
+
+ var outputItemDone = Assert.Single(events, e => e.GetProperty("type").GetString() == "response.output_item.done");
+ var outputText = Assert.Single(outputItemDone.GetProperty("item").GetProperty("content").EnumerateArray());
+ AssertAnnotations(outputText);
+
+ var completed = Assert.Single(events, e => e.GetProperty("type").GetString() == "response.completed");
+ Assert.Equal("response.completed", events[^1].GetProperty("type").GetString());
+ var completedOutputItem = Assert.Single(completed.GetProperty("response").GetProperty("output").EnumerateArray());
+ var completedOutputText = Assert.Single(completedOutputItem.GetProperty("content").EnumerateArray());
+ AssertAnnotations(completedOutputText);
+ }
+
+ [Fact]
+ public async Task MapFoundryResponses_StreamFalse_ReturnsAllAnnotationKindsAsync()
+ {
+ // Arrange and Act
+ var (statusCode, mediaType, body) = await InvokeResponsesEndpointAsync(stream: false);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, statusCode);
+ Assert.Equal("application/json", mediaType);
+
+ using var document = JsonDocument.Parse(body);
+ var outputItem = Assert.Single(document.RootElement.GetProperty("output").EnumerateArray());
+ var outputText = Assert.Single(outputItem.GetProperty("content").EnumerateArray());
+ AssertAnnotations(outputText);
+ }
+
+ private static async Task<(HttpStatusCode StatusCode, string? MediaType, string Body)> InvokeResponsesEndpointAsync(bool stream)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ AIAgent agent = new ChatClientAgent(CreateAnnotationChatClient());
+ builder.Services.AddFoundryResponses(agent, new InMemoryAgentSessionStore());
+ builder.Services.AddSingleton(new FakeHostedSessionIsolationKeyProvider());
+ builder.Services.AddLogging();
+
+ await using var app = builder.Build();
+ app.MapFoundryResponses();
+ await app.StartAsync();
+
+ var testServer = app.Services.GetRequiredService() as TestServer
+ ?? throw new InvalidOperationException("TestServer not found");
+ using var client = testServer.CreateClient();
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/responses")
+ {
+ Content = new StringContent(CreateRequestJson(stream), Encoding.UTF8, "application/json"),
+ };
+ using var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+ return (response.StatusCode, response.Content.Headers.ContentType?.MediaType, body);
+ }
+
+ private static IChatClient CreateAnnotationChatClient()
+ {
+ var annotations = new AIAnnotation[]
+ {
+ new CitationAnnotation
+ {
+ Url = new Uri("https://example.com/doc"),
+ Title = "Example Document",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }]
+ },
+ new CitationAnnotation
+ {
+ FileId = "file_1",
+ Title = "report.pdf",
+ RawRepresentation = new FileCitationMessageAnnotation("file_1", 1, "report.pdf")
+ },
+ new CitationAnnotation
+ {
+ FileId = "file_2",
+ RawRepresentation = new FilePathMessageAnnotation("file_2", 2)
+ },
+ new CitationAnnotation
+ {
+ FileId = "file_3",
+ Title = "chart.png",
+ AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 6, EndIndex = 11 }],
+ RawRepresentation = new ContainerFileCitationMessageAnnotation(
+ "container_1",
+ "file_3",
+ 6,
+ 11,
+ "chart.png")
+ },
+ };
+
+ var client = new Mock();
+ client.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(() => ToAsyncEnumerableUpdatesAsync(
+ new ChatResponseUpdate(ChatRole.Assistant, "Hello sources") { MessageId = "msg_response" },
+ new ChatResponseUpdate(
+ ChatRole.Assistant,
+ [new AIContent { Annotations = annotations }])
+ {
+ MessageId = "msg_response"
+ }));
+ return client.Object;
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerableUpdatesAsync(
+ params ChatResponseUpdate[] updates)
+ {
+ foreach (var update in updates)
+ {
+ yield return update;
+ }
+
+ await Task.CompletedTask;
+ }
+
+ private static string CreateRequestJson(bool stream) => $$"""
+ {
+ "model": "test",
+ "stream": {{(stream ? "true" : "false")}},
+ "input": [
+ {
+ "type": "message",
+ "id": "msg_request",
+ "status": "completed",
+ "role": "user",
+ "content": [{ "type": "input_text", "text": "Hello" }]
+ }
+ ]
+ }
+ """;
+
+ private static async Task> ParseSseEventsAsync(string body)
+ {
+ var events = new List();
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body));
+ await foreach (var item in SseParser.Create(stream).EnumerateAsync())
+ {
+ if (item.Data == "[DONE]")
+ {
+ continue;
+ }
+
+ using var document = JsonDocument.Parse(item.Data);
+ Assert.Equal(document.RootElement.GetProperty("type").GetString(), item.EventType);
+ events.Add(document.RootElement.Clone());
+ }
+
+ return events;
+ }
+
+ private static void AssertAnnotations(JsonElement outputText) =>
+ AssertAnnotations(outputText.GetProperty("annotations").EnumerateArray().ToArray());
+
+ private static void AssertAnnotations(IReadOnlyCollection annotations)
+ {
+ Assert.Collection(
+ annotations,
+ annotation =>
+ {
+ Assert.Equal("url_citation", annotation.GetProperty("type").GetString());
+ Assert.Equal("https://example.com/doc", annotation.GetProperty("url").GetString());
+ Assert.Equal("Example Document", annotation.GetProperty("title").GetString());
+ Assert.Equal(0, annotation.GetProperty("start_index").GetInt64());
+ Assert.Equal(5, annotation.GetProperty("end_index").GetInt64());
+ },
+ annotation =>
+ {
+ Assert.Equal("file_citation", annotation.GetProperty("type").GetString());
+ Assert.Equal("file_1", annotation.GetProperty("file_id").GetString());
+ Assert.Equal(1, annotation.GetProperty("index").GetInt64());
+ Assert.Equal("report.pdf", annotation.GetProperty("filename").GetString());
+ },
+ annotation =>
+ {
+ Assert.Equal("file_path", annotation.GetProperty("type").GetString());
+ Assert.Equal("file_2", annotation.GetProperty("file_id").GetString());
+ Assert.Equal(2, annotation.GetProperty("index").GetInt64());
+ },
+ annotation =>
+ {
+ Assert.Equal("container_file_citation", annotation.GetProperty("type").GetString());
+ Assert.Equal("container_1", annotation.GetProperty("container_id").GetString());
+ Assert.Equal("file_3", annotation.GetProperty("file_id").GetString());
+ Assert.Equal(6, annotation.GetProperty("start_index").GetInt64());
+ Assert.Equal(11, annotation.GetProperty("end_index").GetInt64());
+ Assert.Equal("chart.png", annotation.GetProperty("filename").GetString());
+ });
+ }
+
private static async Task BuildTestHostAsync(
Action configure,
Action? configureBuilder = null)
diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs
index b01af37e0fe..feebac05f85 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs
@@ -1386,6 +1386,7 @@ public async Task FileAccessProvider_UsesProvidedOptionsAsync()
// DisableWriteTools = true => only the read-only tools are exposed.
Assert.Contains(FileAccessProvider.ReadFileToolName, toolNames);
+ Assert.Contains(FileAccessProvider.ReadLinesToolName, toolNames);
Assert.Contains(FileAccessProvider.LsToolName, toolNames);
Assert.Contains(FileAccessProvider.GrepToolName, toolNames);
Assert.DoesNotContain(FileAccessProvider.WriteToolName, toolNames);
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs
index 8ab2c239a31..48999255b2e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs
@@ -24,10 +24,10 @@ public sealed class A2AAgentHandlerTests
private const string ConfigurationPropertyKey = "a2a.configuration";
///
- /// Verifies that when there is no request data to forward, null options are passed to RunStreamingAsync.
+ /// Verifies that when there is no request data to forward, empty options are passed to RunStreamingAsync.
///
[Fact]
- public async Task ExecuteAsync_WhenMetadataIsNull_PassesNullOptionsToRunStreamingAsync()
+ public async Task ExecuteAsync_WhenMetadataIsNull_PassesEmptyOptionsToRunStreamingAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -40,7 +40,9 @@ public async Task ExecuteAsync_WhenMetadataIsNull_PassesNullOptionsToRunStreamin
});
// Assert
- Assert.Null(capturedOptions);
+ Assert.NotNull(capturedOptions);
+ Assert.Null(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
}
///
@@ -146,7 +148,7 @@ public async Task ExecuteAsync_WhenConfigurationRequestsImmediateReturn_DoesNotS
AgentRunOptions? capturedOptions = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(options => capturedOptions = options),
- runMode: AgentRunMode.DisallowBackground);
+ runMode: AgentRunMode.ReturnMessage);
// Act
await InvokeExecuteAsync(handler, new RequestContext
@@ -250,7 +252,7 @@ public async Task ExecuteAsync_DynamicMode_WithFalseCallback_ReturnsMessageAsync
// Arrange
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => { }),
- runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
+ runMode: AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(false)));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -272,7 +274,7 @@ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_ReturnsTaskAsync()
// Arrange
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => { }),
- runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)));
+ runMode: AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(true)));
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -288,10 +290,10 @@ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_ReturnsTaskAsync()
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
///
- /// Verifies that an immediate request emits the initial task and streams subsequent updates when background responses are allowed.
+ /// Verifies that an immediate request emits the initial task and streams subsequent updates in ReturnTask mode.
///
[Fact]
- public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatelyTrue_StreamsTaskUpdatesAsync()
+ public async Task ExecuteAsync_WhenReturnTaskModeAndReturnImmediatelyTrue_StreamsTaskUpdatesAsync()
{
// Arrange
AgentResponseUpdate[] updates =
@@ -306,7 +308,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatel
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -592,7 +594,7 @@ public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync(
}
///
- /// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext.
+ /// Verifies that the dynamic ReturnTaskWhen delegate receives the correct RequestContext.
///
[Fact]
public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
@@ -601,7 +603,7 @@ public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
A2ARunDecisionContext? capturedContext = null;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => { }),
- runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) =>
+ runMode: AgentRunMode.ReturnTaskWhen((ctx, _) =>
{
capturedContext = ctx;
return ValueTask.FromResult(false);
@@ -698,10 +700,10 @@ public async Task ExecuteAsync_Streaming_EnqueuesSingleAggregatedMessageAsync()
}
///
- /// Verifies that allowing background responses emits a task lifecycle in streaming mode.
+ /// Verifies that ReturnTask mode emits a task lifecycle in streaming mode.
///
[Fact]
- public async Task ExecuteAsync_Streaming_WhenBackgroundResponsesAllowed_StreamsTaskUpdatesAsync()
+ public async Task ExecuteAsync_Streaming_WhenReturnTaskMode_StreamsTaskUpdatesAsync()
{
// Arrange
AgentResponseUpdate[] updates =
@@ -730,7 +732,7 @@ public async Task ExecuteAsync_Streaming_WhenBackgroundResponsesAllowed_StreamsT
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -782,7 +784,7 @@ public async Task ExecuteAsync_Streaming_WhenBackgroundResponsesAllowed_StreamsT
/// Verifies that a non-immediate request aggregates all updates into a completed task.
///
[Fact]
- public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatelyFalse_ReturnsCompletedTaskAsync()
+ public async Task ExecuteAsync_WhenReturnTaskModeAndReturnImmediatelyFalse_ReturnsCompletedTaskAsync()
{
// Arrange
AgentResponseUpdate[] updates =
@@ -792,7 +794,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatel
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -831,7 +833,7 @@ public async Task ExecuteAsync_WhenAggregatingTaskUpdates_PreservesArtifactMetad
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -869,7 +871,7 @@ public async Task ExecuteAsync_WhenAggregatedTaskEmissionIsCanceled_CancelsTaskA
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
@@ -917,7 +919,7 @@ public async Task ExecuteAsync_WhenAggregatedTaskEmissionFails_FailsTaskAsync()
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext
@@ -940,10 +942,10 @@ public async Task ExecuteAsync_WhenAggregatedTaskEmissionFails_FailsTaskAsync()
}
///
- /// Verifies that an immediate request returns one aggregated message when background responses are disabled.
+ /// Verifies that an immediate request returns one aggregated message in ReturnMessage mode.
///
[Fact]
- public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmediatelyTrue_ReturnsMessageAsync()
+ public async Task ExecuteAsync_WhenReturnMessageModeAndReturnImmediatelyTrue_ReturnsMessageAsync()
{
// Arrange
AgentResponseUpdate[] updates =
@@ -953,7 +955,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmedia
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.DisallowBackground);
+ runMode: AgentRunMode.ReturnMessage);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -973,10 +975,10 @@ public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmedia
}
///
- /// Verifies that a non-immediate request aggregates all updates into one message when background responses are disabled.
+ /// Verifies that a non-immediate request aggregates all updates into one message in ReturnMessage mode.
///
[Fact]
- public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmediatelyFalse_ReturnsMessageAsync()
+ public async Task ExecuteAsync_WhenReturnMessageModeAndReturnImmediatelyFalse_ReturnsMessageAsync()
{
// Arrange
AgentResponseUpdate[] updates =
@@ -986,7 +988,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmedia
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.DisallowBackground);
+ runMode: AgentRunMode.ReturnMessage);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1021,7 +1023,7 @@ public async Task ExecuteAsync_Streaming_WithoutMessageId_ContinuesCurrentArtifa
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1063,7 +1065,7 @@ public async Task ExecuteAsync_Streaming_WithoutMessageIds_StreamsSingleArtifact
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1106,7 +1108,7 @@ public async Task ExecuteAsync_Streaming_WithEmptyMessageIds_StreamsSingleArtifa
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1156,7 +1158,7 @@ public async Task ExecuteAsync_Streaming_WhenCancellationRequested_CancelsTaskAs
ItExpr.IsAny(),
ItExpr.IsAny())
.Returns(() => ToCancelingAsyncEnumerableAsync(cts));
- A2AAgentHandler handler = CreateHandler(agentMock, runMode: AgentRunMode.AllowBackgroundIfSupported);
+ A2AAgentHandler handler = CreateHandler(agentMock, runMode: AgentRunMode.ReturnTask);
var events = new EventCollector();
var eventQueue = new AgentEventQueue();
var readerTask = ReadEventsAsync(eventQueue, events);
@@ -1199,7 +1201,7 @@ public async Task ExecuteAsync_Streaming_WhenAgentThrows_FailsTaskAsync()
CreateThrowingStreamingAgentMock(
[new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1", MessageId = "m1" }],
new InvalidOperationException("Stream failed")),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext
@@ -1245,7 +1247,7 @@ public async Task ExecuteAsync_Streaming_WhenMessageIdChanges_FinalizesPreviousA
];
A2AAgentHandler handler = CreateHandler(
CreateThrowingStreamingAgentMock(updates, new InvalidOperationException("Stream failed")),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext
@@ -1291,7 +1293,7 @@ public async Task ExecuteAsync_Streaming_WhenMessageIdReappears_UsesDistinctArti
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1345,7 +1347,7 @@ public async Task ExecuteAsync_Streaming_WhenAgentThrowsOperationCanceledWithout
// Arrange
A2AAgentHandler handler = CreateHandler(
CreateThrowingStreamingAgentMock([], new OperationCanceledException("Agent gave up")),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext
@@ -1369,7 +1371,7 @@ public async Task ExecuteAsync_Streaming_WithNoUpdates_CompletesTaskAsync()
// Arrange
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock([]),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1404,7 +1406,7 @@ public async Task ExecuteAsync_Streaming_WithOnlyContentlessUpdates_CompletesTas
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1435,7 +1437,7 @@ public async Task ExecuteAsync_Streaming_WhenContentlessUpdateChangesMessageId_F
];
A2AAgentHandler handler = CreateHandler(
CreateStreamingAgentMock(updates),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ runMode: AgentRunMode.ReturnTask);
// Act
var events = await CollectEventsAsync(handler, new RequestContext
@@ -1500,10 +1502,10 @@ public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditiona
}
///
- /// Verifies that streaming mode passes null options when metadata is null.
+ /// Verifies that streaming mode passes empty options when metadata is null.
///
[Fact]
- public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync()
+ public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesEmptyOptionsAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -1522,7 +1524,9 @@ public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync
// Assert
Assert.True(optionsCaptured);
- Assert.Null(capturedOptions);
+ Assert.NotNull(capturedOptions);
+ Assert.Null(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
}
///
@@ -2072,7 +2076,7 @@ public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsyn
}
///
- /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
+ /// Verifies that when the ReturnTaskWhen delegate throws, the exception propagates
/// and the agent is not invoked.
///
[Fact]
@@ -2082,7 +2086,7 @@ public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptio
bool agentInvoked = false;
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => agentInvoked = true),
- runMode: AgentRunMode.AllowBackgroundWhen((_, _) =>
+ runMode: AgentRunMode.ReturnTaskWhen((_, _) =>
throw new InvalidOperationException("Callback failed")));
// Act & Assert
@@ -2096,7 +2100,7 @@ await Assert.ThrowsAsync(() =>
}
///
- /// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
+ /// Verifies that the CancellationToken is propagated to the ReturnTaskWhen delegate.
///
[Fact]
public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync()
@@ -2106,7 +2110,7 @@ public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallba
using var cts = new CancellationTokenSource();
A2AAgentHandler handler = CreateHandler(
CreateAgentMock(_ => { }),
- runMode: AgentRunMode.AllowBackgroundWhen((_, ct) =>
+ runMode: AgentRunMode.ReturnTaskWhen((_, ct) =>
{
capturedToken = ct;
return ValueTask.FromResult(false);
@@ -2128,17 +2132,20 @@ await handler.ExecuteAsync(
}
///
- /// Verifies that the agent run mode is applied on the continuation/task-update path,
- /// not just the new message path.
+ /// Verifies that the ReturnTaskWhen delegate is not invoked when updating an existing task.
///
[Fact]
- public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync()
+ public async Task ExecuteAsync_OnContinuation_DoesNotInvokeDynamicModeCallbackAsync()
{
// Arrange
- AgentRunOptions? capturedOptions = null;
+ bool callbackInvoked = false;
A2AAgentHandler handler = CreateHandler(
- CreateAgentMock(options => capturedOptions = options),
- runMode: AgentRunMode.AllowBackgroundIfSupported);
+ CreateAgentMock(_ => { }),
+ runMode: AgentRunMode.ReturnTaskWhen((_, _) =>
+ {
+ callbackInvoked = true;
+ return ValueTask.FromResult(false);
+ }));
// Act
await InvokeExecuteAsync(handler, new RequestContext
@@ -2147,13 +2154,11 @@ public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync()
TaskId = "task-1",
ContextId = "ctx-1",
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
-
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
});
// Assert
- Assert.NotNull(capturedOptions);
- Assert.True(capturedOptions.AllowBackgroundResponses);
+ Assert.False(callbackInvoked);
}
///
@@ -2463,7 +2468,7 @@ private static A2AAgentHandler CreateHandler(
AgentRunMode? runMode = null,
AgentSessionStore? agentSessionStore = null)
{
- runMode ??= AgentRunMode.DisallowBackground;
+ runMode ??= AgentRunMode.ReturnMessage;
var hostAgent = new AIHostAgent(
innerAgent: agentMock.Object,
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
index df4669a014a..9d61932417e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
@@ -248,7 +248,7 @@ public void AddA2AServer_WithCustomOptions_Succeeds()
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
- agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
+ agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.ReturnTask);
builder.Services.AddLogging();
using WebApplication app = builder.Build();
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs
index 40905bd6ecb..ce310dbec84 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs
@@ -249,7 +249,7 @@ public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync()
services.AddA2AServer(AgentName, options =>
{
callbackInvoked = true;
- options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported;
+ options.AgentRunMode = AgentRunMode.ReturnTask;
});
// Assert - callback is invoked during resolution
@@ -510,7 +510,7 @@ public async Task AddA2AServer_WithBackgroundResponsesAndNonImmediateRequest_Ret
services.AddKeyedSingleton(AgentName, (_, _) => agentMock.Object);
services.AddA2AServer(
AgentName,
- options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
+ options => options.AgentRunMode = AgentRunMode.ReturnTask);
await using var provider = services.BuildServiceProvider();
var server = provider.GetRequiredKeyedService(AgentName);
SendMessageRequest request = CreateTestSendMessageRequest();
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs
index cbe1254b81c..b9c5e5920ac 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs
@@ -12,25 +12,25 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class AgentRunModeTests
{
///
- /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate.
+ /// Verifies that ReturnTaskWhen throws ArgumentNullException for null delegate.
///
[Fact]
- public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException()
+ public void ReturnTaskWhen_NullDelegate_ThrowsArgumentNullException()
{
// Arrange & Act & Assert
Assert.Throws(() =>
- AgentRunMode.AllowBackgroundWhen(null!));
+ AgentRunMode.ReturnTaskWhen(null!));
}
///
- /// Verifies that DisallowBackground equals another DisallowBackground instance.
+ /// Verifies that ReturnMessage equals another ReturnMessage instance.
///
[Fact]
- public void Equals_DisallowBackground_AreEqual()
+ public void Equals_ReturnMessage_AreEqual()
{
// Arrange
- var mode1 = AgentRunMode.DisallowBackground;
- var mode2 = AgentRunMode.DisallowBackground;
+ var mode1 = AgentRunMode.ReturnMessage;
+ var mode2 = AgentRunMode.ReturnMessage;
// Act & Assert
Assert.True(mode1.Equals(mode2));
@@ -40,14 +40,14 @@ public void Equals_DisallowBackground_AreEqual()
}
///
- /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance.
+ /// Verifies that ReturnTask equals another ReturnTask instance.
///
[Fact]
- public void Equals_AllowBackgroundIfSupported_AreEqual()
+ public void Equals_ReturnTask_AreEqual()
{
// Arrange
- var mode1 = AgentRunMode.AllowBackgroundIfSupported;
- var mode2 = AgentRunMode.AllowBackgroundIfSupported;
+ var mode1 = AgentRunMode.ReturnTask;
+ var mode2 = AgentRunMode.ReturnTask;
// Act & Assert
Assert.True(mode1.Equals(mode2));
@@ -55,19 +55,18 @@ public void Equals_AllowBackgroundIfSupported_AreEqual()
}
///
- /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal.
+ /// Verifies that ReturnMessage and ReturnTask are not equal.
///
[Fact]
public void Equals_DifferentModes_AreNotEqual()
{
// Arrange
- var disallow = AgentRunMode.DisallowBackground;
- var allow = AgentRunMode.AllowBackgroundIfSupported;
+ var message = AgentRunMode.ReturnMessage;
+ var task = AgentRunMode.ReturnTask;
// Act & Assert
- Assert.False(disallow.Equals(allow));
- Assert.False(disallow == allow);
- Assert.True(disallow != allow);
+ Assert.False(message.Equals(task));
+ Assert.False(message == task);
}
///
@@ -77,7 +76,7 @@ public void Equals_DifferentModes_AreNotEqual()
public void Equals_Null_ReturnsFalse()
{
// Arrange
- var mode = AgentRunMode.DisallowBackground;
+ var mode = AgentRunMode.ReturnMessage;
// Act & Assert
Assert.False(mode.Equals(null));
@@ -108,9 +107,9 @@ public void Equals_BothNull_AreEqual()
public void ToString_ReturnsExpectedValues()
{
// Act & Assert
- Assert.Equal("message", AgentRunMode.DisallowBackground.ToString());
- Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString());
- Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString());
+ Assert.Equal("message", AgentRunMode.ReturnMessage.ToString());
+ Assert.Equal("task", AgentRunMode.ReturnTask.ToString());
+ Assert.Equal("dynamic", AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(true)).ToString());
}
///
@@ -120,24 +119,24 @@ public void ToString_ReturnsExpectedValues()
public void Equals_WithObjectParameter_WorksCorrectly()
{
// Arrange
- var mode = AgentRunMode.DisallowBackground;
+ var mode = AgentRunMode.ReturnMessage;
// Act & Assert
- Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground));
- Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported));
+ Assert.True(mode.Equals((object)AgentRunMode.ReturnMessage));
+ Assert.False(mode.Equals((object)AgentRunMode.ReturnTask));
Assert.False(mode.Equals("not a run mode"));
}
///
- /// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal,
+ /// Verifies that two ReturnTaskWhen instances with different delegates are not considered equal,
/// because equality includes delegate identity for dynamic modes.
///
[Fact]
- public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual()
+ public void Equals_ReturnTaskWhen_DifferentDelegates_AreNotEqual()
{
// Arrange
- var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true));
- var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false));
+ var mode1 = AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(true));
+ var mode2 = AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(false));
// Act & Assert
Assert.False(mode1.Equals(mode2));
@@ -145,15 +144,15 @@ public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual()
}
///
- /// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal.
+ /// Verifies that two ReturnTaskWhen instances with the same delegate are considered equal.
///
[Fact]
- public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual()
+ public void Equals_ReturnTaskWhen_SameDelegate_AreEqual()
{
// Arrange
static ValueTask CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true);
- var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
- var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
+ var mode1 = AgentRunMode.ReturnTaskWhen(CallbackAsync);
+ var mode2 = AgentRunMode.ReturnTaskWhen(CallbackAsync);
// Act & Assert
Assert.True(mode1.Equals(mode2));
diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs
index d1b9d535589..b59cc7a3929 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs
@@ -627,7 +627,7 @@ public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeMessageAdditionalPro
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny(), It.IsAny()))
- .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
+ .ReturnsAsync(CreateApplicableProtectionScopesResponse());
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny(), It.IsAny()))
.ReturnsAsync(new ProcessContentResponse { PolicyActions = [] });
@@ -668,7 +668,7 @@ public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeAuthorName_Async()
this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
It.IsAny(), It.IsAny()))
- .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
+ .ReturnsAsync(CreateApplicableProtectionScopesResponse());
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny(), It.IsAny()))
.ReturnsAsync(new ProcessContentResponse { PolicyActions = [] });
@@ -703,6 +703,9 @@ public async Task ProcessMessagesAsync_UsesProvidedUserId_WhenTokenUserIdIsEmpty
It.IsAny(), It.IsAny()))
.ReturnsAsync((ProtectionScopesResponse?)null);
+ this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(CreateApplicableProtectionScopesResponse());
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
It.IsAny(), It.IsAny()))
.ReturnsAsync(new ProcessContentResponse { PolicyActions = [] });
@@ -750,7 +753,7 @@ public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenV
}
[Fact]
- public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync()
+ public async Task ProcessMessagesAsync_CacheMiss_CallsProcessContentInlineAndQueuesScopeRetrievalAsync()
{
// Arrange
var messages = new List
@@ -767,19 +770,26 @@ public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCalls
.ReturnsAsync((ProtectionScopesResponse?)null);
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
- It.IsAny(), It.IsAny()))
+ It.Is(request =>
+ request.ScopeIdentifier == null &&
+ request.ProcessInline),
+ It.IsAny()))
.ReturnsAsync(new ProcessContentResponse());
// Act
await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
- // Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job.
+ // Assert
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
- It.IsAny(), It.IsAny()), Times.Once);
+ It.Is(request =>
+ request.ScopeIdentifier == null &&
+ request.ProcessInline),
+ It.IsAny()), Times.Once);
this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync(
It.IsAny(), It.IsAny()), Times.Never);
- this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once);
+ this._mockChannelHandler.Verify(x => x.QueueJob(
+ It.Is(job => job.ProcessContentRequest.ProcessInline)), Times.Once);
}
[Fact]
@@ -799,17 +809,16 @@ public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_R
It.IsAny(), It.IsAny()))
.ReturnsAsync((ProtectionScopesResponse?)null);
- var pcResponse = new ProcessContentResponse
- {
- PolicyActions =
- [
- new() { Action = DlpAction.BlockAccess }
- ]
- };
-
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
- It.IsAny(), It.IsAny()))
- .ReturnsAsync(pcResponse);
+ It.Is(request => request.ProcessInline),
+ It.IsAny()))
+ .ReturnsAsync(new ProcessContentResponse
+ {
+ PolicyActions =
+ [
+ new() { Action = DlpAction.BlockAccess }
+ ]
+ });
// Act
var result = await this._processor.ProcessMessagesAsync(
@@ -817,11 +826,13 @@ public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_R
// Assert
Assert.True(result.shouldBlock);
- this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once);
+ this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
+ It.Is(request => request.ProcessInline),
+ It.IsAny()), Times.Once);
}
[Fact]
- public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync()
+ public async Task ProcessMessagesAsync_CacheMiss_WhenScopeRetrievalCannotQueue_CallsProcessContentInlineAsync()
{
// Arrange
var messages = new List
@@ -841,17 +852,19 @@ public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenSco
.Throws(new PurviewJobException("queue unavailable"));
this._mockPurviewClient.Setup(x => x.ProcessContentAsync(
- It.IsAny(), It.IsAny()))
+ It.Is(request => request.ProcessInline),
+ It.IsAny()))
.ReturnsAsync(new ProcessContentResponse());
// Act
await this._processor.ProcessMessagesAsync(
messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None);
- // Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued.
+ // Assert
this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once);
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
- It.IsAny(), It.IsAny()), Times.Once);
+ It.Is(request => request.ProcessInline),
+ It.IsAny()), Times.Once);
}
[Fact]
@@ -878,11 +891,11 @@ await Assert.ThrowsAsync(() =>
this._mockPurviewClient.Verify(x => x.ProcessContentAsync(
It.IsAny(), It.IsAny()), Times.Never);
- this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never);
+ this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never);
}
[Fact]
- public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
+ public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_CachesAndQueuesContentActivityAsync()
{
// Arrange
Func, Task>? runner = null;
@@ -890,40 +903,65 @@ public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSub
Mock purviewClient = new();
Mock cacheProvider = new();
PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
- ProtectionScopesRequest request = new("user-123", "tenant-123")
- {
- Activities = ProtectionScopeActivities.UploadText,
- Locations =
- [
- new("microsoft.graph.policyLocationApplication", "app-123")
- ]
- };
+ ProtectionScopesRequest request = CreateProtectionScopesRequest();
ProtectionScopesCacheKey cacheKey = new(request);
+ ScopeRetrievalJob job = new(request, cacheKey, CreateProcessContentRequest());
+ ProtectionScopesResponse response = new() { ScopeIdentifier = "scope-123", Scopes = [] };
Channel channel = Channel.CreateUnbounded();
channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>()))
.Callback, Task>>(callback => runner = callback);
+ purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(response);
+ _ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
+
+ // Act
+ Assert.NotNull(runner);
+ await channel.Writer.WriteAsync(job);
+ channel.Writer.Complete();
+ await runner(channel);
+
+ // Assert
+ cacheProvider.Verify(x => x.SetAsync(cacheKey, response, It.IsAny()), Times.Once);
+ channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task BackgroundJobRunner_ScopeRetrievalApplicableScope_CachesWithoutContentActivityAsync()
+ {
+ // Arrange
+ Func, Task>? runner = null;
+ Mock channelHandler = new();
+ Mock purviewClient = new();
+ Mock cacheProvider = new();
+ PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 };
+ ProtectionScopesRequest request = CreateProtectionScopesRequest();
+ ProtectionScopesCacheKey cacheKey = new(request);
+ ScopeRetrievalJob job = new(request, cacheKey, CreateProcessContentRequest());
+ ProtectionScopesResponse response = CreateApplicableProtectionScopesResponse();
+ Channel channel = Channel.CreateUnbounded();
+
+ channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>()))
+ .Callback, Task>>(callback => runner = callback);
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny()))
- .ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
+ .ReturnsAsync(response);
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
// Act
Assert.NotNull(runner);
- await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest()));
+ await channel.Writer.WriteAsync(job);
channel.Writer.Complete();
await runner(channel);
// Assert
- cacheProvider.Verify(x => x.SetAsync(
- It.Is(key => key.TenantId == "tenant-123"),
- It.Is(entry => entry.Message == "Payment required"),
- It.IsAny()), Times.Once);
+ cacheProvider.Verify(x => x.SetAsync(cacheKey, response, It.IsAny()), Times.Once);
+ channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never);
}
[Fact]
- public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync()
+ public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync()
{
// Arrange
Func, Task>? runner = null;
@@ -937,9 +975,8 @@ public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesCon
channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>()))
.Callback, Task>>(callback => runner = callback);
-
purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] });
+ .ThrowsAsync(new PurviewPaymentRequiredException("Payment required"));
_ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings);
@@ -950,7 +987,10 @@ public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesCon
await runner(channel);
// Assert
- channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once);
+ cacheProvider.Verify(x => x.SetAsync(
+ It.Is(key => key.TenantId == "tenant-123"),
+ It.Is(entry => entry.Message == "Payment required"),
+ It.IsAny()), Times.Once);
}
#endregion
@@ -969,6 +1009,26 @@ private static ProtectionScopesRequest CreateProtectionScopesRequest()
};
}
+ private static ProtectionScopesResponse CreateApplicableProtectionScopesResponse(ExecutionMode executionMode = ExecutionMode.EvaluateInline)
+ {
+ return new ProtectionScopesResponse
+ {
+ ScopeIdentifier = "scope-123",
+ Scopes =
+ [
+ new()
+ {
+ Activities = ProtectionScopeActivities.UploadText,
+ Locations =
+ [
+ new("microsoft.graph.policyLocationApplication", "app-123")
+ ],
+ ExecutionMode = executionMode
+ }
+ ]
+ };
+ }
+
private static ProcessContentRequest CreateProcessContentRequest()
{
PurviewTextContent content = new("Test content");
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs
index e8797a4bc9b..eb0cd8d2521 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs
@@ -43,8 +43,8 @@ public async Task ProvideAIContextAsync_ReturnsToolsAsync()
// Arrange
var tools = await CreateToolsAsync();
- // Assert â 7 tools: Read, Ls, Grep, Write, Delete, Replace, ReplaceLines
- Assert.Equal(7, tools.Count());
+ // Assert â 8 tools: Read, ReadLines, Ls, Grep, Write, Delete, Replace, ReplaceLines
+ Assert.Equal(8, tools.Count());
}
#endregion
@@ -58,7 +58,7 @@ public async Task ProvideAIContextAsync_AllToolsRequireApprovalAsync()
var tools = await CreateToolsAsync();
// Assert â every tool is wrapped so that it always requires approval.
- Assert.Equal(7, tools.Count());
+ Assert.Equal(8, tools.Count());
Assert.All(tools, tool => Assert.IsType(tool));
}
@@ -70,6 +70,7 @@ public async Task DisableReadOnlyToolApproval_ReadOnlyToolsNotWrappedAsync()
// Assert â read-only tools are bare functions; store-modifying tools still require approval.
AssertRequiresApproval(tools, FileAccessProvider.ReadFileToolName, expected: false);
+ AssertRequiresApproval(tools, FileAccessProvider.ReadLinesToolName, expected: false);
AssertRequiresApproval(tools, FileAccessProvider.LsToolName, expected: false);
AssertRequiresApproval(tools, FileAccessProvider.GrepToolName, expected: false);
AssertRequiresApproval(tools, FileAccessProvider.WriteToolName, expected: true);
@@ -86,6 +87,7 @@ public async Task DisableWriteToolApproval_WriteToolsNotWrappedAsync()
// Assert â store-modifying tools are bare functions; read-only tools still require approval.
AssertRequiresApproval(tools, FileAccessProvider.ReadFileToolName, expected: true);
+ AssertRequiresApproval(tools, FileAccessProvider.ReadLinesToolName, expected: true);
AssertRequiresApproval(tools, FileAccessProvider.LsToolName, expected: true);
AssertRequiresApproval(tools, FileAccessProvider.GrepToolName, expected: true);
AssertRequiresApproval(tools, FileAccessProvider.WriteToolName, expected: false);
@@ -105,7 +107,7 @@ public async Task DisableBothToolApprovals_NoToolsWrappedAsync()
})).ToList();
// Assert â no tool requires approval.
- Assert.Equal(7, tools.Count);
+ Assert.Equal(8, tools.Count);
Assert.DoesNotContain(tools, tool => tool is ApprovalRequiredAIFunction);
}
@@ -117,6 +119,7 @@ private static void AssertRequiresApproval(IEnumerable tools, string too
[Theory]
[InlineData(FileAccessProvider.ReadFileToolName, true)]
+ [InlineData(FileAccessProvider.ReadLinesToolName, true)]
[InlineData(FileAccessProvider.LsToolName, true)]
[InlineData(FileAccessProvider.GrepToolName, true)]
[InlineData(FileAccessProvider.WriteToolName, false)]
@@ -138,6 +141,7 @@ public async Task ReadOnlyToolsAutoApprovalRule_ApprovesOnlyReadOnlyToolsAsync(s
[Theory]
[InlineData(FileAccessProvider.ReadFileToolName, true)]
+ [InlineData(FileAccessProvider.ReadLinesToolName, true)]
[InlineData(FileAccessProvider.LsToolName, true)]
[InlineData(FileAccessProvider.GrepToolName, true)]
[InlineData(FileAccessProvider.WriteToolName, true)]
@@ -375,6 +379,174 @@ public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync()
#endregion
+ #region ReadLines Tests
+
+ [Fact]
+ public async Task ReadLines_ReturnsNumberedInclusiveRangeAsync()
+ {
+ // Arrange
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("notes.md", "one\ntwo\nthree\nfour\n");
+ var tools = await CreateToolsAsync(store);
+ var readLines = GetTool(tools, "file_access_read_lines");
+
+ // Act
+ var result = await InvokeToolAsync(readLines, new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["startLine"] = 2,
+ ["endLine"] = 3,
+ });
+
+ // Assert â each line keeps its terminator, which doubles as the row separator.
+ var text = Assert.IsType(result).GetString();
+ Assert.Equal("2\ttwo\n3\tthree\n", text);
+ }
+
+ [Fact]
+ public async Task ReadLines_OmittedEndLine_ReadsToEndOfFileAsync()
+ {
+ // Arrange
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("notes.md", "one\ntwo\nthree");
+ var tools = await CreateToolsAsync(store);
+ var readLines = GetTool(tools, "file_access_read_lines");
+
+ // Act
+ var result = await InvokeToolAsync(readLines, new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["startLine"] = 2,
+ });
+
+ // Assert â the last line has no terminator, so the output ends without one.
+ var text = Assert.IsType(result).GetString();
+ Assert.Equal("2\ttwo\n3\tthree", text);
+ }
+
+ [Fact]
+ public async Task ReadLines_EndLinePastLastLine_IsClampedAsync()
+ {
+ // Arrange
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("notes.md", "one\ntwo\n");
+ var tools = await CreateToolsAsync(store);
+ var readLines = GetTool(tools, "file_access_read_lines");
+
+ // Act
+ var result = await InvokeToolAsync(readLines, new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["startLine"] = 1,
+ ["endLine"] = 99,
+ });
+
+ // Assert â clamping, not an error.
+ var text = Assert.IsType(result).GetString();
+ Assert.Equal("1\tone\n2\ttwo\n", text);
+ }
+
+ [Fact]
+ public async Task ReadLines_PreservesCrlfTerminatorsAsync()
+ {
+ // Arrange
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("notes.md", "alpha\r\nbeta\r\n");
+ var tools = await CreateToolsAsync(store);
+ var readLines = GetTool(tools, "file_access_read_lines");
+
+ // Act
+ var result = await InvokeToolAsync(readLines, new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["startLine"] = 1,
+ ["endLine"] = 1,
+ });
+
+ // Assert â the line's own terminator is reported, so no detection step is needed.
+ var text = Assert.IsType(result).GetString();
+ Assert.Equal("1\talpha\r\n", text);
+ }
+
+ [Fact]
+ public async Task ReadLines_NonExistent_ReturnsNotFoundMessageAsync()
+ {
+ // Arrange
+ var tools = await CreateToolsAsync();
+ var readLines = GetTool(tools, "file_access_read_lines");
+
+ // Act
+ var result = await InvokeToolAsync(readLines, new AIFunctionArguments
+ {
+ ["fileName"] = "nonexistent.md",
+ ["startLine"] = 1,
+ });
+
+ // Assert â same shape as file_access_read.
+ var text = Assert.IsType(result).GetString();
+ Assert.Contains("not found", text);
+ }
+
+ [Fact]
+ public async Task ReadLines_StartLinePastLastLine_ThrowsAsync()
+ {
+ // Arrange
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("notes.md", "one\ntwo\n");
+ var tools = await CreateToolsAsync(store);
+ var readLines = GetTool(tools, "file_access_read_lines");
+
+ // Act & Assert â exception bubbles, as it does for replace_lines.
+ await Assert.ThrowsAsync(async () =>
+ await InvokeToolAsync(readLines, new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["startLine"] = 3,
+ }));
+ }
+
+ [Fact]
+ public async Task ReadLines_RoundTripsAGrepMatchIntoReplaceLinesAsync()
+ {
+ // Arrange â a CRLF file with a trailing newline, the case where the terminator used to be lost.
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("notes.md", "alpha\r\nbeta needle\r\ngamma\r\n");
+ var tools = await CreateToolsAsync(store);
+
+ // Act â grep for the line, read that line number back, then feed the result to replace_lines.
+ var grepResult = await InvokeToolAsync(GetTool(tools, "file_access_grep"), new AIFunctionArguments
+ {
+ ["regexPattern"] = "needle",
+ });
+ JsonElement match = Assert.IsType(grepResult).EnumerateArray().Single()
+ .GetProperty("matchingLines").EnumerateArray().Single();
+ int lineNumber = match.GetProperty("lineNumber").GetInt32();
+
+ var readResult = await InvokeToolAsync(GetTool(tools, "file_access_read_lines"), new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["startLine"] = lineNumber,
+ ["endLine"] = lineNumber,
+ });
+ string shown = Assert.IsType(readResult).GetString()!;
+
+ // Everything after the number and tab is the line verbatim, so it is already a valid new_line.
+ string line = shown.Substring(shown.IndexOf('\t') + 1);
+ await InvokeToolAsync(GetTool(tools, "file_access_replace_lines"), new AIFunctionArguments
+ {
+ ["fileName"] = "notes.md",
+ ["edits"] = new List { new() { LineNumber = lineNumber, NewLine = line.ToUpperInvariant() } },
+ });
+
+ // Assert â grep, read_lines and replace_lines agree on line 2, and the CRLF survives.
+ Assert.Equal(2, lineNumber);
+ Assert.Equal("beta needle\r\n", match.GetProperty("line").GetString());
+ Assert.Equal("2\tbeta needle\r\n", shown);
+ Assert.Equal("alpha\r\nBETA NEEDLE\r\ngamma\r\n", await store.ReadAsync("notes.md"));
+ }
+
+ #endregion
+
#region DeleteFile Tests
[Fact]
@@ -874,8 +1046,9 @@ public async Task Options_DisableWriteTools_OnlyExposesReadOnlyToolsAsync()
var names = result.Tools!.OfType().Select(t => t.Name).ToList();
// Assert â only read-only tools are exposed.
- Assert.Equal(3, names.Count);
+ Assert.Equal(4, names.Count);
Assert.Contains(FileAccessProvider.ReadFileToolName, names);
+ Assert.Contains(FileAccessProvider.ReadLinesToolName, names);
Assert.Contains(FileAccessProvider.LsToolName, names);
Assert.Contains(FileAccessProvider.GrepToolName, names);
Assert.DoesNotContain(FileAccessProvider.WriteToolName, names);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs
index 77dc5c7c4b7..d6002c75665 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs
@@ -975,12 +975,12 @@ public async Task Write_WithDescription_ReturnsWrittenWithDescriptionAsync()
#region Helper Methods
- private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func? stateInitializer = null)
+ private static FileMemoryProvider CreateProvider(AgentFileStore? store = null, Func? stateInitializer = null)
{
return new FileMemoryProvider(store ?? new InMemoryAgentFileStore(), stateInitializer);
}
- private static async Task<(IEnumerable Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(InMemoryAgentFileStore? store = null, Func? stateInitializer = null)
+ private static async Task<(IEnumerable Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(AgentFileStore? store = null, Func? stateInitializer = null)
{
var provider = CreateProvider(store, stateInitializer);
var agent = new Mock().Object;
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs
new file mode 100644
index 00000000000..027bdd48eb6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs
@@ -0,0 +1,257 @@
+īģŋ// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
+
+///
+/// Unit tests for the line-numbering contract on : the published split,
+/// the numbering primitive, the narrowing hook, the base
+/// built on top of them, and the guards that keep a store's line numbers honest.
+///
+public class AgentFileStoreContractTests
+{
+ private const string Needle = "keep me";
+
+ ///
+ /// A store implementing only the mandatory members. Before the contract this could not exist:
+ /// SearchAsync was abstract. It now inherits the base implementation and must produce line
+ /// numbers that address the same lines the editor edits.
+ ///
+ private class ContentOnlyStore : AgentFileStore
+ {
+ public Dictionary Files { get; } = [];
+
+ public override Task WriteAsync(string path, string content, CancellationToken cancellationToken = default)
+ {
+ this.Files[path] = content;
+ return Task.CompletedTask;
+ }
+
+ public override Task ReadAsync(string path, CancellationToken cancellationToken = default)
+ => Task.FromResult(this.Files.TryGetValue(path, out string? value) ? value : null);
+
+ public override Task DeleteAsync(string path, CancellationToken cancellationToken = default)
+ => Task.FromResult(this.Files.Remove(path));
+
+ public override Task> ListChildrenAsync(string directory, CancellationToken cancellationToken = default)
+ {
+ string prefix = string.IsNullOrEmpty(directory) ? string.Empty : directory + "/";
+ var seen = new Dictionary();
+ foreach (string path in this.Files.Keys)
+ {
+ if (!path.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ string tail = path.Substring(prefix.Length);
+ int slash = tail.IndexOf('/');
+ seen[slash < 0 ? tail : tail.Substring(0, slash)] = slash < 0 ? FileStoreEntry.File : FileStoreEntry.Directory;
+ }
+
+ return Task.FromResult>(
+ seen.Select(kvp => new FileStoreEntry(kvp.Key, kvp.Value)).ToList());
+ }
+
+ public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default)
+ => Task.FromResult(this.Files.ContainsKey(path));
+
+ public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+ }
+
+ /// A store whose narrowing hook consults an index instead of listing everything.
+ private sealed class NarrowingStore : ContentOnlyStore
+ {
+ public HashSet Indexed { get; } = [];
+
+ protected override Task> FindMatchingFilesAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default)
+ => Task.FromResult>(this.Indexed.OrderBy(x => x, StringComparer.Ordinal).ToList());
+ }
+
+ [Fact]
+ public void SplitLines_PublishesTheEditorsRule()
+ {
+ foreach (string content in new[] { "a\nb\n", "a\rb", "", "x", "a\r\nb\r\n" })
+ {
+ // Assert
+ Assert.Equal(FileEditor.SplitLinesKeepEnds(content), AgentFileStore.SplitLines(content));
+ }
+ }
+
+ [Fact]
+ public void ScanContent_NumbersBySplitLines()
+ {
+ // Act
+ FileSearchResult? result = AgentFileStore.ScanContent("f.txt", "alpha\r\nbeta match\r\ngamma\r\n", new Regex("match", RegexOptions.IgnoreCase));
+
+ // Assert
+ Assert.NotNull(result);
+ FileSearchMatch match = result!.MatchingLines[0];
+ Assert.Equal(AgentFileStore.SplitLines("alpha\r\nbeta match\r\ngamma\r\n")[match.LineNumber - 1], match.Line);
+ Assert.Equal("beta match\r\n", match.Line);
+ }
+
+ [Fact]
+ public async Task StoreWithoutSearch_UsesBasePathAndStaysAlignedAsync()
+ {
+ // Arrange
+ var store = new ContentOnlyStore();
+ const string Raw = "alpha\r\nDEBUG = 1\r\nkeep me\r\nDEBUG = 2\r\n";
+ await store.WriteAsync("cfg.txt", Raw);
+
+ // Act
+ IReadOnlyList results = await store.SearchAsync(string.Empty, Needle, recursive: true);
+
+ // Assert: the number grep reports addresses the line the editor will touch.
+ FileSearchMatch match = Assert.Single(Assert.Single(results).MatchingLines);
+ Assert.Equal(3, match.LineNumber);
+ Assert.Equal("keep me\r\n", match.Line);
+ Assert.Equal(match.Line, FileEditor.SliceLines(Raw, match.LineNumber, match.LineNumber)[0]);
+ }
+
+ [Fact]
+ public async Task BaseSearch_ReappliesGlobAndRecursionWhenAStoreOverReturnsAsync()
+ {
+ // Arrange: the hook returns everything, ignoring both the glob and the recursion flag.
+ var store = new NarrowingStore();
+ await store.WriteAsync("top.md", Needle);
+ await store.WriteAsync("notes.txt", Needle);
+ await store.WriteAsync("nested/deep.md", Needle);
+ foreach (string name in store.Files.Keys)
+ {
+ store.Indexed.Add(name);
+ }
+
+ // Act
+ IReadOnlyList topLevelMarkdown = await store.SearchAsync(string.Empty, Needle, "*.md", recursive: true);
+ IReadOnlyList allMarkdown = await store.SearchAsync(string.Empty, Needle, "**/*.md", recursive: true);
+ IReadOnlyList shallow = await store.SearchAsync(string.Empty, Needle, recursive: false);
+
+ // Assert: the glob is re-applied, using this SDK's Matcher semantics where "*" does not
+ // cross "/" (unlike the Python side's fnmatch, where it does).
+ Assert.Equal(["top.md"], topLevelMarkdown.Select(r => r.FileName));
+ Assert.Equal(["nested/deep.md", "top.md"], allMarkdown.Select(r => r.FileName).OrderBy(x => x, StringComparer.Ordinal));
+
+ // And the non-recursive rule still excludes the nested file.
+ Assert.Equal(["notes.txt", "top.md"], shallow.Select(r => r.FileName).OrderBy(x => x, StringComparer.Ordinal));
+ }
+
+ [Fact]
+ public async Task BaseSearch_NarrowsThroughTheHookAsync()
+ {
+ // Arrange: three files match, but only one is indexed. Under-returning breaks the hook's
+ // contract; it is done here because nothing else proves the hook chose what got read.
+ var store = new NarrowingStore();
+ for (int i = 0; i < 3; i++)
+ {
+ await store.WriteAsync($"f{i}.txt", $"alpha\n{Needle}\n");
+ }
+
+ store.Indexed.Add("f1.txt");
+
+ // Act
+ IReadOnlyList results = await store.SearchAsync(string.Empty, Needle, recursive: true);
+
+ // Assert: narrowing decides what is read; the base still numbers it.
+ Assert.Equal("f1.txt", Assert.Single(results).FileName);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ }
+
+ [Fact]
+ public void ApplyReplaceLines_ExpectedLineMatching_AppliesTheEdit()
+ {
+ // Act
+ string result = FileEditor.ApplyReplaceLines(
+ "one\ntwo\nthree\n",
+ [new FileLineEdit { LineNumber = 2, NewLine = "TWO\n", ExpectedLine = "two" }]);
+
+ // Assert
+ Assert.Equal("one\nTWO\nthree\n", result);
+ }
+
+ [Fact]
+ public void ApplyReplaceLines_ExpectedLineDiffering_Throws()
+ {
+ // Act + Assert: a stale or mis-numbered edit is refused rather than applied.
+ ArgumentException error = Assert.Throws(() =>
+ FileEditor.ApplyReplaceLines(
+ "one\ntwo\nthree\n",
+ [new FileLineEdit { LineNumber = 3, NewLine = "X\n", ExpectedLine = "two" }]));
+
+ Assert.Contains("does not match the expected text", error.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ApplyReplaceLines_ExpectedLineIgnoresTheTerminator()
+ {
+ // Act: a line fed straight back from grep still carries its terminator.
+ string result = FileEditor.ApplyReplaceLines(
+ "alpha\r\nbeta\r\n",
+ [new FileLineEdit { LineNumber = 2, NewLine = "BETA\r\n", ExpectedLine = "beta\r\n" }]);
+
+ // Assert
+ Assert.Equal("alpha\r\nBETA\r\n", result);
+ }
+
+ [Fact]
+ public void ApplyReplaceLines_WithoutExpectedLine_IsUnchanged()
+ {
+ // Act: the guard is opt-in.
+ string result = FileEditor.ApplyReplaceLines("one\ntwo\n", [new FileLineEdit { LineNumber = 1, NewLine = "ONE\n" }]);
+
+ // Assert
+ Assert.Equal("ONE\ntwo\n", result);
+ }
+
+ /// Lists children without observing the token, and counts how often it is asked.
+ private sealed class CountingListStore : ContentOnlyStore
+ {
+ public int Listings { get; private set; }
+
+ public override Task> ListChildrenAsync(string directory, CancellationToken cancellationToken = default)
+ {
+ this.Listings++;
+ return base.ListChildrenAsync(directory, CancellationToken.None);
+ }
+ }
+
+ [Fact]
+ public async Task BaseSearch_StopsWalkingWhenCancelledEvenIfTheStoreIgnoresTheTokenAsync()
+ {
+ // Arrange â twenty directories to walk, and a store that takes the token and never reads it,
+ // which is the shape that leaves a cancelled walk enumerating the whole hierarchy.
+ var store = new CountingListStore();
+ for (int index = 0; index < 20; index++)
+ {
+ await store.WriteAsync($"dir{index}/f.txt", Needle);
+ }
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act
+ await Assert.ThrowsAnyAsync(
+ () => store.SearchAsync(string.Empty, Needle, recursive: true, cancellationToken: cts.Token));
+
+ // Assert â the walk must stop at once. Throwing alone proves nothing here, because
+ // SearchAsync checks the token itself once FindMatchingFilesAsync has already returned.
+ Assert.Equal(0, store.Listings);
+ }
+
+ [Fact]
+ public void ScanContent_NullFileName_Throws()
+ {
+ // Arrange â a pattern that matches, since a non-matching scan returns null and hides the problem.
+ var regex = new Regex(Needle, RegexOptions.IgnoreCase);
+
+ // Act & Assert
+ Assert.Throws(() => AgentFileStore.ScanContent(null!, Needle, regex));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs
index 505e761ca9d..99b4f37eae8 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Text.RegularExpressions;
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
@@ -178,4 +179,130 @@ public void ApplyReplaceLines_EmbeddedNewLine_ExpandsIntoMultipleLines()
}
#endregion
+
+ #region SplitLinesKeepEnds
+
+ [Theory]
+ [InlineData("a\nb\nc", new[] { "a\n", "b\n", "c" })]
+ [InlineData("a\nb\n", new[] { "a\n", "b\n" })]
+ [InlineData("a\r\nb\r\n", new[] { "a\r\n", "b\r\n" })]
+ [InlineData("a\rb\rc", new[] { "a\r", "b\r", "c" })]
+ [InlineData("a\r\nb\nc\r", new[] { "a\r\n", "b\n", "c\r" })]
+ [InlineData("single", new[] { "single" })]
+ [InlineData("", new string[0])]
+ public void SplitLinesKeepEnds_KeepsEachLinesOwnTerminator(string content, string[] expected)
+ {
+ // Act
+ List lines = FileEditor.SplitLinesKeepEnds(content);
+
+ // Assert
+ Assert.Equal(expected, lines);
+ }
+
+ [Fact]
+ public void SplitLinesKeepEnds_ConcatenationRoundTripsTheContent()
+ {
+ // Arrange â mixed terminators, the case a whole-file read would otherwise be needed to detect.
+ const string Content = "alpha\r\nbeta\ngamma\rdelta";
+
+ // Act
+ List lines = FileEditor.SplitLinesKeepEnds(Content);
+
+ // Assert â nothing is lost or added, which is what makes a reported line reusable verbatim.
+ Assert.Equal(Content, string.Concat(lines));
+ }
+
+ [Theory]
+ [InlineData("match\r\n", "match")]
+ [InlineData("match\n", "match")]
+ [InlineData("match\r", "match")]
+ [InlineData("match", "match")]
+ [InlineData("", "")]
+ [InlineData("a\rb\n", "a\rb")]
+ public void LineContentLength_ExcludesOnlyTheTrailingTerminator(string line, string expected)
+ {
+ // Act
+ int length = FileEditor.LineContentLength(line);
+
+ // Assert â the length delimits exactly the line's text, which is the range searches match over.
+ Assert.Equal(expected.Length, length);
+ Assert.Equal(expected, line.Substring(0, length));
+ }
+
+ [Theory]
+ [InlineData("beta match\r\n")]
+ [InlineData("beta match\n")]
+ [InlineData("beta match\r")]
+ [InlineData("beta match")]
+ public void LineContentLength_BoundsAnEndAnchoredMatch(string line)
+ {
+ // Arrange â the callers match over a range instead of a trimmed copy, so '$' has to anchor at
+ // the returned length rather than at the end of the string.
+ var regex = new Regex("match$", RegexOptions.IgnoreCase);
+
+ // Act
+ Match match = regex.Match(line, 0, FileEditor.LineContentLength(line));
+
+ // Assert
+ Assert.True(match.Success);
+ Assert.Equal(5, match.Index);
+ }
+
+ #endregion
+
+ #region SliceLines
+
+ [Fact]
+ public void SliceLines_ReturnsInclusiveRangeWithTerminators()
+ {
+ // Act
+ List lines = FileEditor.SliceLines("one\ntwo\nthree\nfour\n", 2, 3);
+
+ // Assert
+ Assert.Equal(2, lines.Count);
+ Assert.Equal("two\nthree\n", string.Concat(lines));
+ }
+
+ [Fact]
+ public void SliceLines_NullEndLine_ReadsToEndOfContent()
+ {
+ // Act
+ List lines = FileEditor.SliceLines("one\ntwo\nthree", 2, endLine: null);
+
+ // Assert
+ Assert.Equal(2, lines.Count);
+ Assert.Equal("two\nthree", string.Concat(lines));
+ }
+
+ [Fact]
+ public void SliceLines_EndLinePastLastLine_IsClamped()
+ {
+ // Act
+ List lines = FileEditor.SliceLines("one\ntwo\n", 1, 99);
+
+ // Assert
+ Assert.Equal(2, lines.Count);
+ Assert.Equal("one\ntwo\n", string.Concat(lines));
+ }
+
+ [Theory]
+ [InlineData(0, null)]
+ [InlineData(-1, null)]
+ [InlineData(1, 0)]
+ [InlineData(3, 2)]
+ [InlineData(4, null)]
+ public void SliceLines_InvalidRange_Throws(int startLine, int? endLine)
+ {
+ // Act & Assert â "one\ntwo\nthree" has three lines.
+ Assert.Throws(() => FileEditor.SliceLines("one\ntwo\nthree", startLine, endLine));
+ }
+
+ [Fact]
+ public void SliceLines_EmptyContent_HasNoAddressableLines()
+ {
+ // Act & Assert â matches ApplyReplaceLines, which also rejects line 1 of an empty file.
+ Assert.Throws(() => FileEditor.SliceLines(string.Empty, 1, null));
+ }
+
+ #endregion
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs
index 20d1a8333d6..c8b9600028d 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs
@@ -293,6 +293,112 @@ public async Task SearchFilesAsync_FindsMatchAsync()
Assert.Contains("error", results[0].Snippet);
}
+ // Both stores number through AgentFileStore.ScanContent, so the line, terminator and snippet-offset
+ // rules are pinned once in AgentFileStoreContractTests. These cover the same ground against real
+ // files, where content arrives through a decoded read rather than an in-memory string.
+
+ [Fact]
+ public async Task SearchFilesAsync_ReportsLinesVerbatimAsync()
+ {
+ // Arrange
+ await this._store.WriteAsync("notes.md", "Line one\nLine two with match\nLine three\nLine four with match");
+
+ // Act
+ var results = await this._store.SearchAsync("", "match");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal(2, results[0].MatchingLines.Count);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ // Lines are reported verbatim, so an interior line keeps its terminator.
+ Assert.Equal("Line two with match\n", results[0].MatchingLines[0].Line);
+ Assert.Equal(4, results[0].MatchingLines[1].LineNumber);
+ // The last line has no terminator in the content, so none is reported.
+ Assert.Equal("Line four with match", results[0].MatchingLines[1].Line);
+ }
+
+ [Fact]
+ public async Task SearchFilesAsync_ReportsCrlfLinesVerbatimAsync()
+ {
+ // Arrange
+ await this._store.WriteAsync("notes.md", "alpha\r\nbeta match\r\ngamma\r\n");
+
+ // Act
+ var results = await this._store.SearchAsync("", "match");
+
+ // Assert â the CRLF is preserved, so the line can be fed back to replace_lines unchanged.
+ Assert.Single(results);
+ Assert.Single(results[0].MatchingLines);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ Assert.Equal("beta match\r\n", results[0].MatchingLines[0].Line);
+ }
+
+ [Fact]
+ public async Task SearchFilesAsync_TrailingNewline_DoesNotReportAnExtraLineAsync()
+ {
+ // Arrange â a newline-terminated file has as many lines as the line editor sees, not one more.
+ await this._store.WriteAsync("notes.md", "a\nb\n");
+
+ // Act â a pattern that also matches an empty line.
+ var results = await this._store.SearchAsync("", "^.*$");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal(2, results[0].MatchingLines.Count);
+ Assert.Equal("a\n", results[0].MatchingLines[0].Line);
+ Assert.Equal("b\n", results[0].MatchingLines[1].Line);
+ }
+
+ [Fact]
+ public async Task SearchFilesAsync_LoneCarriageReturn_SplitsLikeTheLineEditorAsync()
+ {
+ // Arrange â a lone '\r' terminates a line for the line editor, so grep must agree.
+ await this._store.WriteAsync("notes.md", "alpha\rbeta match\rgamma");
+
+ // Act
+ var results = await this._store.SearchAsync("", "match");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Single(results[0].MatchingLines);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ Assert.Equal("beta match\r", results[0].MatchingLines[0].Line);
+ }
+
+ [Theory]
+ [InlineData("alpha\r\nbeta match\r\ngamma\r\n")]
+ [InlineData("alpha\rbeta match\rgamma")]
+ [InlineData("alpha\nbeta match\ngamma\n")]
+ public async Task SearchFilesAsync_EndAnchoredPatternMatchesRegardlessOfTerminatorAsync(string content)
+ {
+ // Arrange â the pattern anchors to the end of the line's text, which is "beta match".
+ await this._store.WriteAsync("notes.md", content);
+
+ // Act
+ var results = await this._store.SearchAsync("", "match$");
+
+ // Assert â the terminator is not part of the text the pattern is matched against.
+ Assert.Single(results);
+ Assert.Single(results[0].MatchingLines);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ }
+
+ [Fact]
+ public async Task SearchFilesAsync_SnippetIsAnchoredAtTheMatchAsync()
+ {
+ // Arrange â the leading line is long enough that the Âą50 char snippet window is not clamped to
+ // the start of the file, so an off-by-one in the per-line offset would shift the snippet.
+ string padding = new('x', 60);
+ await this._store.WriteAsync("notes.md", $"{padding}\nneedle\n");
+
+ // Act
+ var results = await this._store.SearchAsync("", "needle");
+
+ // Assert â the match starts at index 61, so the snippet starts at index 11.
+ Assert.Single(results);
+ Assert.Equal($"{new string('x', 49)}\nneedle\n", results[0].Snippet);
+ }
+
[Fact]
public async Task SearchFilesAsync_GlobFilter_ExcludesNonMatchingAsync()
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs
index 722dc8f7356..7907007a039 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs
@@ -207,11 +207,106 @@ public async Task SearchFiles_ReturnsMatchingLineNumbersAsync()
Assert.Single(results);
Assert.Equal(2, results[0].MatchingLines.Count);
Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
- Assert.Equal("Line two with match", results[0].MatchingLines[0].Line);
+ // Lines are reported verbatim, so an interior line keeps its terminator.
+ Assert.Equal("Line two with match\n", results[0].MatchingLines[0].Line);
Assert.Equal(4, results[0].MatchingLines[1].LineNumber);
+ // The last line has no terminator in the content, so none is reported.
Assert.Equal("Line four with match", results[0].MatchingLines[1].Line);
}
+ [Fact]
+ public async Task SearchFiles_ReportsCrlfLinesVerbatimAsync()
+ {
+ // Arrange
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("folder/notes.md", "alpha\r\nbeta match\r\ngamma\r\n");
+
+ // Act
+ var results = await store.SearchAsync("folder", "match");
+
+ // Assert â the CRLF is preserved, so the line can be fed back to replace_lines unchanged.
+ Assert.Single(results);
+ Assert.Single(results[0].MatchingLines);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ Assert.Equal("beta match\r\n", results[0].MatchingLines[0].Line);
+ }
+
+ [Fact]
+ public async Task SearchFiles_TrailingNewline_DoesNotReportAnExtraLineAsync()
+ {
+ // Arrange â a newline-terminated file has as many lines as the line editor sees, not one more.
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("folder/notes.md", "a\nb\n");
+
+ // Act â a pattern that also matches an empty line.
+ var results = await store.SearchAsync("folder", "^.*$");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal(2, results[0].MatchingLines.Count);
+ Assert.Equal("a\n", results[0].MatchingLines[0].Line);
+ Assert.Equal("b\n", results[0].MatchingLines[1].Line);
+ }
+
+ [Fact]
+ public async Task SearchFiles_LoneCarriageReturn_SplitsLikeTheLineEditorAsync()
+ {
+ // Arrange â a lone '\r' terminates a line for the line editor, so grep must agree.
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("folder/notes.md", "alpha\rbeta match\rgamma");
+
+ // Act
+ var results = await store.SearchAsync("folder", "match");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Single(results[0].MatchingLines);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ Assert.Equal("beta match\r", results[0].MatchingLines[0].Line);
+ }
+
+ [Theory]
+ [InlineData("alpha\r\nbeta match\r\ngamma\r\n")]
+ [InlineData("alpha\rbeta match\rgamma")]
+ [InlineData("alpha\nbeta match\ngamma\n")]
+ public async Task SearchFiles_EndAnchoredPatternMatchesRegardlessOfTerminatorAsync(string content)
+ {
+ // Arrange â the pattern anchors to the end of the line's text, which is "beta match".
+ var store = new InMemoryAgentFileStore();
+ await store.WriteAsync("folder/notes.md", content);
+
+ // Act
+ var results = await store.SearchAsync("folder", "match$");
+
+ // Assert â the terminator is not part of the text the pattern is matched against.
+ Assert.Single(results);
+ Assert.Single(results[0].MatchingLines);
+ Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
+ }
+
+ [Theory]
+ [InlineData("\n")]
+ [InlineData("\r")]
+ [InlineData("\r\n")]
+ public async Task SearchFiles_SnippetIsAnchoredAtTheMatchAsync(string terminator)
+ {
+ // Arrange â the leading line is long enough that the Âą50 char snippet window is not clamped to
+ // the start of the file, so an off-by-one in the per-line offset would shift the snippet. Every
+ // terminator length is covered: advancing by content length plus one would pass LF and CR but
+ // fall a character short on CRLF.
+ var store = new InMemoryAgentFileStore();
+ string padding = new('x', 60);
+ await store.WriteAsync("folder/notes.md", $"{padding}{terminator}needle{terminator}");
+
+ // Act
+ var results = await store.SearchAsync("folder", "needle");
+
+ // Assert â the snippet starts 50 characters before the match, which lands that many characters
+ // into the padding minus the terminator the match sits behind.
+ Assert.Single(results);
+ Assert.Equal($"{new string('x', 50 - terminator.Length)}{terminator}needle{terminator}", results[0].Snippet);
+ }
+
[Fact]
public async Task SearchFiles_CaseInsensitiveAsync()
{
diff --git a/python/README.md b/python/README.md
index 2a672e5f3c3..13ff3e006aa 100644
--- a/python/README.md
+++ b/python/README.md
@@ -258,4 +258,6 @@ For more advanced orchestration patterns including Sequential, Concurrent, Group
- [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python)
- [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet)
- [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design)
-- Learn docs are coming soon.
+- [Learn: Agent Framework Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)
+- [Learn: Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)
+- [Learn: Tutorials](https://learn.microsoft.com/agent-framework/get-started/)
diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py
index 3d5ac41e86b..1261305bfbc 100644
--- a/python/packages/a2a/agent_framework_a2a/_agent.py
+++ b/python/packages/a2a/agent_framework_a2a/_agent.py
@@ -263,6 +263,8 @@ def __init__(
super().__init__(id=id, name=name, description=description, **kwargs)
self._http_client: httpx.AsyncClient | None = http_client
+ # every construction path must set this before __aexit__ can run
+ self._close_http_client = False
self._timeout_config = self._create_timeout_config(timeout)
bindings = supported_protocol_bindings if supported_protocol_bindings is not None else ["JSONRPC"]
if client is not None:
diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py
index 8ff147f98aa..9c111a2b11e 100644
--- a/python/packages/a2a/tests/test_a2a_agent.py
+++ b/python/packages/a2a/tests/test_a2a_agent.py
@@ -2536,3 +2536,37 @@ async def test_input_required_sets_task_id_instead_of_reference(mock_a2a_client:
# endregion
+
+
+async def test_context_manager_with_user_supplied_http_client() -> None:
+ """A2AAgent(url=..., http_client=mine) must exit cleanly and must not close it.
+
+ The third construction path (no client, caller-provided http_client) used to
+ leave _close_http_client unset, so __aexit__ raised AttributeError.
+ """
+ mock_http_client = MagicMock()
+ mock_http_client.aclose = AsyncMock()
+
+ agent = A2AAgent(url="http://agent.example", http_client=mock_http_client)
+
+ async with agent:
+ pass
+
+ # the client belongs to the caller; we must not close it
+ mock_http_client.aclose.assert_not_called()
+
+
+async def test_context_manager_closes_self_created_http_client() -> None:
+ """A2AAgent(url=...) builds its own client and closes it on exit."""
+
+ with patch("agent_framework_a2a._agent.httpx.AsyncClient") as cls:
+ mock_http_client = MagicMock()
+ mock_http_client.aclose = AsyncMock()
+ cls.return_value = mock_http_client
+
+ agent = A2AAgent(url="http://agent.example")
+
+ async with agent:
+ pass
+
+ mock_http_client.aclose.assert_called_once()
diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
index a5ccd1a9cac..73b2a1d7ec0 100644
--- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
+++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
@@ -87,12 +87,11 @@ def __init__(self, storage: CheckpointStorage, owner: WorkflowRequestOwner) -> N
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
"""Save a checkpoint with ownership for any pending request occurrences."""
- if checkpoint.pending_request_info_events:
- checkpoint.metadata = dict(checkpoint.metadata)
- checkpoint.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] = {
- "snapshot_scope": self._owner[0],
- "thread_id": self._owner[1],
- }
+ checkpoint.metadata = dict(checkpoint.metadata)
+ checkpoint.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] = {
+ "snapshot_scope": self._owner[0],
+ "thread_id": self._owner[1],
+ }
return await self._storage.save(checkpoint)
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
@@ -398,9 +397,8 @@ async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[BaseEvent]:
code="WORKFLOW_CHECKPOINT_LOAD_FAILED",
)
return
- checkpoint_pending_ids = {str(request_id) for request_id in checkpoint.pending_request_info_events}
checkpoint_owner = _checkpoint_request_owner(checkpoint.metadata)
- if checkpoint_pending_ids and checkpoint_owner != request_owner:
+ if checkpoint_owner is not None and checkpoint_owner != request_owner:
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
yield RunErrorEvent(
message=f"No pending interrupt found for checkpointId '{checkpoint_id}'.",
diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py
index 866086fea77..f14aa0e206c 100644
--- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py
+++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py
@@ -910,6 +910,17 @@ def _workflow_payload_to_contents(payload: Any) -> list[Content] | None:
if isinstance(payload, AgentResponseUpdate):
contents = list(payload.contents or [])
role_field = payload.role
+ if role_field is None:
+ # ``role`` is optional and streamed continuation chunks routinely omit it.
+ # Keep their text -- previously dropped, so role-less text surfaced as a
+ # CUSTOM workflow_output instead of reasoning/assistant text -- alongside tool
+ # content. Approval requests stay excluded (see _TOOL_CONTENT_TYPES): a
+ # role-less approval interrupt from streamed content has no pending request to
+ # resume against.
+ role_less_contents = [
+ content for content in contents if content.type == "text" or content.type in _TOOL_CONTENT_TYPES
+ ]
+ return role_less_contents or None
if isinstance(role_field, str):
role = role_field
else:
@@ -933,6 +944,30 @@ def _workflow_payload_to_contents(payload: Any) -> list[Content] | None:
return None
+def _as_reasoning_content(content: Content) -> Content:
+ """Re-tag plain text content as ``text_reasoning``.
+
+ Intermediate workflow output should surface as AG-UI reasoning (a collapsible
+ "thinking" block) rather than a final assistant message. Only ``text`` content
+ is converted; tool calls, results, and other content types pass through
+ unchanged so they still emit as their native AG-UI events.
+ """
+ if content.type != "text":
+ return content
+ return Content.from_text_reasoning(
+ id=content.id,
+ text=content.text,
+ # Carry encrypted reasoning metadata through unchanged: _emit_text_reasoning
+ # turns protected_data into a ReasoningEncryptedValueEvent and an
+ # ``encryptedValue`` on the snapshot entry, so dropping it here would break
+ # reasoning state continuity for intermediate content that carries it.
+ protected_data=content.protected_data,
+ annotations=content.annotations,
+ additional_properties=content.additional_properties or None,
+ raw_representation=content.raw_representation,
+ )
+
+
def _event_name(event: Any) -> str:
event_type = getattr(event, "type", None)
if isinstance(event_type, str) and event_type:
@@ -1115,6 +1150,22 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
flow.accumulated_text = ""
return [TextMessageEndEvent(message_id=current_message_id)]
+ def _drain_open_blocks() -> list[BaseEvent]:
+ """Close any open reasoning block and assistant text message.
+
+ Emitted before content that must not sit inside an open block: a terminal event
+ (RUN_FINISHED / RUN_ERROR, which must be the final events in the stream) or a
+ request_info tool call (non-reasoning message content). Otherwise the block's
+ REASONING_* / TEXT_MESSAGE_* end events would be flushed only by the post-loop
+ cleanup -- after the terminal event, or after the tool call. Both inner helpers
+ are no-ops when their block is not open, so this is always safe to call (a later
+ cleanup pass then simply does nothing).
+ """
+ events: list[BaseEvent] = []
+ events.extend(_close_reasoning_block(flow))
+ events.extend(_drain_open_message())
+ return events
+
fwd_kwargs: dict[str, Any] = {}
if "forwarded_props" in input_data:
forwarded_props = input_data["forwarded_props"]
@@ -1170,6 +1221,10 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
run_started_emitted = True
if event_type == "failed":
+ # Close any open reasoning block / text message so RUN_ERROR stays the
+ # last event a client receives for this run.
+ for end_event in _drain_open_blocks():
+ yield end_event
details = getattr(event, "details", None)
yield RunErrorEvent(message=_details_message(details), code=_details_code(details))
run_error_emitted = True
@@ -1183,9 +1238,9 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
else:
state_value = str(getattr(state, "value", state))
if state_value in _TERMINAL_STATES and not terminal_emitted:
- # Close any open assistant text message before the terminal event so
- # RUN_FINISHED is always the last emitted event.
- for end_event in _drain_open_message():
+ # Close any open reasoning block and assistant text message before the
+ # terminal event so RUN_FINISHED is always the last emitted event.
+ for end_event in _drain_open_blocks():
yield end_event
if not interrupts:
interrupts.extend(_interrupts_from_pending_requests(await _pending_request_events(workflow)))
@@ -1238,7 +1293,10 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
continue
if event_type == "request_info":
- for end_event in _drain_open_message():
+ # A request_info emits a tool call (non-reasoning message content), so any
+ # open reasoning block / text message must be closed first -- otherwise the
+ # tool call would sit inside an unclosed reasoning block.
+ for end_event in _drain_open_blocks():
yield end_event
request_payload = _request_payload_from_request_event(event)
if request_payload is None:
@@ -1258,7 +1316,12 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
yield CustomEvent(name=_INTERRUPT_CARD_EVENT_NAME, value=interrupt_event_value)
continue
- if event_type in {"output", "data"}:
+ if event_type in {"output", "intermediate", "data"}:
+ # "intermediate" (and its deprecated alias "data") carry non-terminal
+ # output. Their text is surfaced as AG-UI reasoning so consumers render
+ # it as a collapsible "thinking" block instead of a final assistant
+ # message. "output" keeps the terminal-message behavior.
+ is_intermediate = event_type in {"intermediate", "data"}
output_payload = getattr(event, "data", None)
if isinstance(output_payload, BaseEvent):
yield output_payload
@@ -1277,15 +1340,25 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
yield out_event
contents = _workflow_payload_to_contents(output_payload)
if contents:
- output_text = _text_from_contents(contents)
- skip_text = bool(output_text and output_text == last_assistant_text)
- for content in contents:
- for out_event in _emit_content(content, flow, predictive_handler=None, skip_text=skip_text):
- yield out_event
- if flow.message_id and flow.accumulated_text:
- last_assistant_text = flow.accumulated_text.strip() or last_assistant_text
- elif output_text:
- last_assistant_text = output_text
+ if is_intermediate:
+ # Reasoning is a separate channel from the final assistant
+ # message, so the last_assistant_text dedup does not apply.
+ for content in contents:
+ reasoning_content = _as_reasoning_content(content)
+ for out_event in _emit_content(
+ reasoning_content, flow, predictive_handler=None, skip_text=False
+ ):
+ yield out_event
+ else:
+ output_text = _text_from_contents(contents)
+ skip_text = bool(output_text and output_text == last_assistant_text)
+ for content in contents:
+ for out_event in _emit_content(content, flow, predictive_handler=None, skip_text=skip_text):
+ yield out_event
+ if flow.message_id and flow.accumulated_text:
+ last_assistant_text = flow.accumulated_text.strip() or last_assistant_text
+ elif output_text:
+ last_assistant_text = output_text
else:
yield CustomEvent(name="workflow_output", value=make_json_safe(output_payload))
continue
@@ -1298,6 +1371,9 @@ def _drain_open_message() -> list[TextMessageEndEvent]:
if not run_started_emitted:
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
run_started_emitted = True
+ # Close any open reasoning block / text message so RUN_ERROR stays the final event.
+ for end_event in _drain_open_blocks():
+ yield end_event
if not run_error_emitted:
yield RunErrorEvent(message=str(exc), code=type(exc).__name__)
run_error_emitted = True
diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml
index 116e727137c..09f114de5f4 100644
--- a/python/packages/ag-ui/pyproject.toml
+++ b/python/packages/ag-ui/pyproject.toml
@@ -24,7 +24,7 @@ classifiers = [
dependencies = [
"agent-framework-core>=1.17.0,<2",
"ag-ui-protocol>=0.1.19,<0.2",
- "fastapi>=0.121.0,<0.140.0",
+ "fastapi>=0.121.0,<0.142.0",
"httpx>=0.28.1,<1",
"sse-starlette>=3.4.5,<4",
"uvicorn[standard]>=0.30.0,<1"
diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py
index 120e387817b..6f38bd8f26e 100644
--- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py
+++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py
@@ -32,6 +32,7 @@
SupportsAgentRun,
ToolApprovalMiddleware,
WorkflowBuilder,
+ WorkflowCheckpoint,
WorkflowContext,
WorkflowExecutor,
executor,
@@ -55,7 +56,11 @@
from agent_framework_ag_ui._agent import AgentFrameworkAgent
from agent_framework_ag_ui._approval_lifecycle import ApprovalExecutionOwner, ApprovalLifecycle, ApprovalStatus
from agent_framework_ag_ui._approval_state import InMemoryAGUIApprovalStateStore, approval_state_thread_id
-from agent_framework_ag_ui._workflow import AgentFrameworkWorkflow
+from agent_framework_ag_ui._workflow import (
+ _CHECKPOINT_REQUEST_OWNER_KEY,
+ AgentFrameworkWorkflow,
+ _OwnedWorkflowCheckpointStorage,
+)
def _decode_sse_events(response: Any) -> list[dict[str, Any]]:
@@ -5283,6 +5288,76 @@ async def test_endpoint_workflow_request_info_rejects_unowned_pending_interrupt(
assert attacker_errors[0]["code"] == "WORKFLOW_RESUME_NOT_FOUND"
+async def test_owned_checkpoint_storage_stamps_owner_without_pending_events() -> None:
+ """The request owner is stamped on every save, not only when pending request events exist."""
+ storage = InMemoryCheckpointStorage()
+ owned_storage = _OwnedWorkflowCheckpointStorage(storage, ("scope-1", "thread-1"))
+ checkpoint = WorkflowCheckpoint(workflow_name="owned-workflow", graph_signature_hash="signature")
+ assert not checkpoint.pending_request_info_events
+
+ checkpoint_id = await owned_storage.save(checkpoint)
+
+ expected_owner = {"snapshot_scope": "scope-1", "thread_id": "thread-1"}
+ assert checkpoint.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] == expected_owner
+ stored = await storage.load(checkpoint_id)
+ assert stored.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] == expected_owner
+
+
+async def test_endpoint_workflow_checkpoint_resume_rejects_foreign_clean_checkpoint():
+ """A checkpoint with no pending request events is still owned and cannot be resumed by another thread."""
+ storage = InMemoryCheckpointStorage()
+ first_app = FastAPI()
+ first_workflow = _build_flight_choice_workflow()
+ add_agent_framework_fastapi_endpoint(
+ first_app,
+ first_workflow,
+ path="/workflow",
+ checkpoint_storage=storage,
+ )
+
+ with TestClient(first_app) as client:
+ pause_response = client.post(
+ "/workflow",
+ json={
+ "runId": "run-pause",
+ "threadId": "victim-thread",
+ "messages": [{"role": "user", "content": "Book me a flight"}],
+ },
+ )
+ assert pause_response.status_code == 200
+
+ checkpoints = await storage.list_checkpoints(workflow_name=first_workflow.name)
+ clean_checkpoints = [checkpoint for checkpoint in checkpoints if not checkpoint.pending_request_info_events]
+ assert clean_checkpoints, "expected at least one checkpoint without pending request events"
+ checkpoint = min(clean_checkpoints, key=lambda checkpoint: checkpoint.timestamp)
+ assert checkpoint.metadata.get(_CHECKPOINT_REQUEST_OWNER_KEY) is not None
+
+ second_app = FastAPI()
+ add_agent_framework_fastapi_endpoint(
+ second_app,
+ _build_flight_choice_workflow(),
+ path="/workflow",
+ checkpoint_storage=storage,
+ )
+
+ with TestClient(second_app) as client:
+ attacker_response = client.post(
+ "/workflow",
+ json={
+ "runId": "run-attacker",
+ "threadId": "attacker-thread",
+ "messages": [],
+ "forwardedProps": {"checkpointId": checkpoint.checkpoint_id},
+ },
+ )
+
+ attacker_events = _decode_sse_events(attacker_response)
+ attacker_errors = [event for event in attacker_events if event.get("type") == "RUN_ERROR"]
+ assert len(attacker_errors) == 1
+ assert attacker_errors[0]["code"] == "WORKFLOW_RESUME_NOT_FOUND"
+ assert not [event for event in attacker_events if event.get("type") == "TEXT_MESSAGE_CONTENT"]
+
+
async def test_endpoint_workflow_checkpoint_resume_rejects_threaded_resume_after_restart():
"""An explicitly threaded cold checkpoint resume fails closed when ownership is unavailable."""
storage = InMemoryCheckpointStorage()
diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py
index 7ee5d835c5f..f1cd16c9593 100644
--- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py
+++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py
@@ -34,6 +34,7 @@
from pydantic import BaseModel
from agent_framework_ag_ui._workflow_run import (
+ _as_reasoning_content,
_coerce_content,
_coerce_json_value,
_coerce_message,
@@ -120,6 +121,178 @@ async def start(message: Any, ctx: WorkflowContext[Any, str]) -> None:
assert custom_events[0].value == {"progress": 10} # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+async def test_workflow_run_maps_intermediate_text_to_reasoning_events():
+ """Intermediate workflow output is surfaced as AG-UI reasoning, not a generic CustomEvent."""
+
+ @executor(id="thinker")
+ async def thinker(message: Any, ctx: WorkflowContext[str, str]) -> None:
+ # Intermediate-designated executor: text should render as reasoning.
+ await ctx.yield_output("Analyzing the problem...")
+ await ctx.send_message("go")
+
+ @executor(id="finalizer")
+ async def finalizer(message: str, ctx: WorkflowContext[None, str]) -> None:
+ # Output-designated executor: final assistant text.
+ await ctx.yield_output("Here's my answer!")
+
+ workflow = (
+ WorkflowBuilder(
+ start_executor=thinker,
+ output_from=[finalizer],
+ intermediate_output_from=[thinker],
+ )
+ .add_edge(thinker, finalizer)
+ .build()
+ )
+ input_data = {"messages": [{"role": "user", "content": "solve it"}]}
+
+ events = [event async for event in run_workflow_stream(input_data, workflow)]
+ event_types = [event.type for event in events]
+
+ # The intermediate text renders as reasoning ...
+ assert "REASONING_MESSAGE_CONTENT" in event_types
+ reasoning_deltas = [event.delta for event in events if event.type == "REASONING_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ assert "Analyzing the problem..." in reasoning_deltas
+
+ # ... and is not swallowed by the generic custom-event fallback.
+ assert not [event for event in events if event.type == "CUSTOM" and event.name == "intermediate"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+ # The final output still renders as an assistant text message.
+ assert "TEXT_MESSAGE_CONTENT" in event_types
+ text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ assert "Here's my answer!" in text_deltas
+
+
+async def test_workflow_run_maps_data_alias_text_to_reasoning_events():
+ """The deprecated ``type='data'`` alias is treated like ``intermediate`` and renders as reasoning."""
+
+ @executor(id="emitter")
+ async def emitter(message: Any, ctx: WorkflowContext[Any, str]) -> None:
+ # Deprecated compatibility alias for an intermediate emission.
+ await ctx.add_event(WorkflowEvent.emit("emitter", "legacy reasoning"))
+ await ctx.yield_output("final answer")
+
+ workflow = WorkflowBuilder(start_executor=emitter).build()
+ input_data = {"messages": [{"role": "user", "content": "go"}]}
+
+ with pytest.warns(DeprecationWarning):
+ events = [event async for event in run_workflow_stream(input_data, workflow)]
+
+ reasoning_deltas = [event.delta for event in events if event.type == "REASONING_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ assert "legacy reasoning" in reasoning_deltas
+ assert not [event for event in events if event.type == "CUSTOM" and event.name == "data"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+ # The genuine output is still emitted as an assistant text message.
+ text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ assert "final answer" in text_deltas
+
+
+def test_as_reasoning_content_preserves_protected_data():
+ """Re-tagging text as reasoning keeps encrypted protected_data and passes non-text through."""
+ text = Content("text", text="thinking", protected_data="enc-blob")
+
+ reasoning = _as_reasoning_content(text)
+
+ assert reasoning.type == "text_reasoning"
+ assert reasoning.text == "thinking"
+ # Without this the ReasoningEncryptedValueEvent and snapshot encryptedValue are lost.
+ assert reasoning.protected_data == "enc-blob"
+
+ # Non-text content (e.g. a tool call) is returned unchanged.
+ call = Content.from_function_call(call_id="c1", name="tool", arguments="{}")
+ assert _as_reasoning_content(call) is call
+
+
+async def test_workflow_run_closes_reasoning_before_run_finished():
+ """Intermediate reasoning with no terminal text still closes before the terminal event."""
+
+ @executor(id="thinker")
+ async def thinker(message: Any, ctx: WorkflowContext[Any, str]) -> None:
+ # Intermediate output only -- no terminal assistant text follows.
+ await ctx.yield_output("Thinking, but no final answer...")
+
+ workflow = WorkflowBuilder(
+ start_executor=thinker,
+ output_from=[],
+ intermediate_output_from=[thinker],
+ ).build()
+ input_data = {"messages": [{"role": "user", "content": "go"}]}
+
+ events = [event async for event in run_workflow_stream(input_data, workflow)]
+ event_types = [event.type for event in events]
+
+ assert "REASONING_END" in event_types
+ assert "RUN_FINISHED" in event_types
+ run_finished_idx = next(i for i, event in enumerate(events) if event.type == "RUN_FINISHED")
+ # Every reasoning event -- including the closing REASONING_MESSAGE_END / REASONING_END --
+ # must precede RUN_FINISHED so clients that stop at the terminal event get a complete stream.
+ reasoning_idxs = [i for i, event in enumerate(events) if "REASONING" in str(event.type)]
+ assert reasoning_idxs
+ assert max(reasoning_idxs) < run_finished_idx
+
+
+async def test_workflow_run_closes_reasoning_before_request_info():
+ """An open reasoning block is closed before a request_info tool call (not left spanning it)."""
+
+ @executor(id="asker")
+ async def asker(message: Any, ctx: WorkflowContext[Any, str]) -> None:
+ # Intermediate reasoning, then a human-in-the-loop request in the same run.
+ await ctx.yield_output("Thinking before I ask...")
+ await ctx.request_info("Need approval", str, request_id="approval-1")
+
+ workflow = WorkflowBuilder(
+ start_executor=asker,
+ output_from=[],
+ intermediate_output_from=[asker],
+ ).build()
+ input_data = {"messages": [{"role": "user", "content": "go"}]}
+
+ events = [event async for event in run_workflow_stream(input_data, workflow)]
+ event_types = [event.type for event in events]
+
+ assert "REASONING_END" in event_types
+ reasoning_end_idx = max(i for i, event in enumerate(events) if "REASONING" in str(event.type))
+ request_start_idx = next(
+ i
+ for i, event in enumerate(events)
+ if event.type == "TOOL_CALL_START" and getattr(event, "tool_call_id", None) == "approval-1"
+ )
+ # The reasoning block must be fully closed before the request_info tool call.
+ assert reasoning_end_idx < request_start_idx
+
+
+async def test_workflow_run_roleless_intermediate_update_becomes_reasoning():
+ """A role-less AgentResponseUpdate on the intermediate path surfaces its text as reasoning."""
+
+ @executor(id="thinker")
+ async def thinker(message: Any, ctx: WorkflowContext[str, Any]) -> None:
+ # role=None is the common shape for streamed continuation chunks.
+ await ctx.yield_output(AgentResponseUpdate(contents=[Content.from_text("Role-less thought")], role=None))
+ await ctx.send_message("go")
+
+ @executor(id="finalizer")
+ async def finalizer(message: str, ctx: WorkflowContext[None, str]) -> None:
+ await ctx.yield_output("Done.")
+
+ workflow = (
+ WorkflowBuilder(
+ start_executor=thinker,
+ output_from=[finalizer],
+ intermediate_output_from=[thinker],
+ )
+ .add_edge(thinker, finalizer)
+ .build()
+ )
+ input_data = {"messages": [{"role": "user", "content": "go"}]}
+
+ events = [event async for event in run_workflow_stream(input_data, workflow)]
+
+ reasoning_deltas = [event.delta for event in events if event.type == "REASONING_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+ assert "Role-less thought" in reasoning_deltas
+ # The role-less text must not be dropped into a generic custom event.
+ assert not [event for event in events if event.type == "CUSTOM" and event.name == "workflow_output"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
+
+
async def test_workflow_and_agent_spans_use_supplied_agui_thread_id(monkeypatch: pytest.MonkeyPatch) -> None:
"""Workflow spans use supplied AG-UI threads without replacing provider fallback behavior."""
import agent_framework.observability as observability
@@ -1838,9 +2011,10 @@ def test_agent_response_update_non_assistant(self):
assert _workflow_payload_to_contents(update) is None
def test_agent_response_update_none_role(self):
- """AgentResponseUpdate with None role returns None."""
- update = AgentResponseUpdate(contents=[Content.from_text(text="hi")], role=None)
- assert _workflow_payload_to_contents(update) is None
+ """AgentResponseUpdate with None role keeps text (role-less continuation chunks)."""
+ text = Content.from_text(text="hi")
+ update = AgentResponseUpdate(contents=[text], role=None)
+ assert _workflow_payload_to_contents(update) == [text]
def test_agent_response_update_function_call_without_role(self) -> None:
"""Function call content passes through without role metadata."""
@@ -1883,11 +2057,19 @@ def test_agent_response_update_mcp_tool_result_without_role(self) -> None:
assert _workflow_payload_to_contents(update) == [mcp_result]
def test_agent_response_update_mixed_content_without_role(self) -> None:
- """Non-assistant updates keep tool content and drop text content."""
+ """Role-less updates keep both text and tool content in order."""
text = Content.from_text(text="calling the tool")
function_call = Content.from_function_call(call_id="call-1", name="search", arguments={"query": "weather"})
update = AgentResponseUpdate(contents=[text, function_call], role=None)
+ assert _workflow_payload_to_contents(update) == [text, function_call]
+
+ def test_agent_response_update_explicit_non_assistant_role_drops_text(self) -> None:
+ """An explicit non-assistant role still keeps only tool content and drops text."""
+ text = Content.from_text(text="calling the tool")
+ function_call = Content.from_function_call(call_id="call-1", name="search", arguments={"query": "weather"})
+ update = AgentResponseUpdate(contents=[text, function_call], role="tool")
+
assert _workflow_payload_to_contents(update) == [function_call]
def test_agent_response_update_assistant_text(self) -> None:
diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py
index f3fab4ca26f..7b0cf4eb792 100644
--- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py
+++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py
@@ -32,8 +32,18 @@
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework._tools import SHELL_TOOL_KIND_VALUE, normalize_tools
from agent_framework._types import _get_data_bytes_as_str # type: ignore
+from agent_framework.exceptions import (
+ AgentFrameworkException,
+ ChatClientException,
+ ChatClientInvalidAuthException,
+ ChatClientInvalidRequestException,
+)
from agent_framework.observability import ChatTelemetryLayer
+from anthropic import APIError as AnthropicAPIError
from anthropic import AsyncAnthropic, AsyncAnthropicFoundry
+from anthropic import AuthenticationError as AnthropicAuthenticationError
+from anthropic import BadRequestError as AnthropicBadRequestError
+from anthropic import PermissionDeniedError as AnthropicPermissionDeniedError
from anthropic.lib.bedrock import AsyncAnthropicBedrock
from anthropic.lib.vertex import AsyncAnthropicVertex
from anthropic.types.beta import (
@@ -88,6 +98,23 @@
AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex
+def _wrap_anthropic_error(ex: Exception) -> ChatClientException:
+ """Translate a raw anthropic-sdk failure into the framework's ChatClientException hierarchy.
+
+ ``APIError`` instances are classified by HTTP status (401/403 -> auth, other 4xx ->
+ invalid request), matching the Mistral client. Anything else - connection errors,
+ timeouts, unexpected SDK exceptions - is still wrapped as a generic ``ChatClientException``
+ so callers catching that base type never see a raw provider exception leak through.
+ """
+ if isinstance(ex, AnthropicAPIError):
+ status = getattr(ex, "status_code", None)
+ if isinstance(ex, (AnthropicAuthenticationError, AnthropicPermissionDeniedError)) or status in (401, 403):
+ return ChatClientInvalidAuthException(f"Anthropic authentication failed: {ex}", inner_exception=ex)
+ if isinstance(ex, AnthropicBadRequestError) or (isinstance(status, int) and 400 <= status < 500):
+ return ChatClientInvalidRequestException(f"Invalid Anthropic request: {ex}", inner_exception=ex)
+ return ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=ex)
+
+
# region Anthropic Chat Options TypedDict
@@ -566,17 +593,27 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
# accumulator to _process_stream_event to emit increments instead.
emitted_usage: dict[str, int] = {}
mark_feature_used(FeatureIndex.ANTHROPIC)
- async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
- parsed_chunk = self._process_stream_event(chunk, emitted_usage)
- if parsed_chunk:
- yield parsed_chunk
+ try:
+ async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
+ parsed_chunk = self._process_stream_event(chunk, emitted_usage)
+ if parsed_chunk:
+ yield parsed_chunk
+ except AgentFrameworkException:
+ raise
+ except Exception as ex:
+ raise _wrap_anthropic_error(ex) from ex
return self._build_response_stream(_stream(), response_format=options.get("response_format"))
# Non-streaming mode
async def _get_response() -> ChatResponse:
mark_feature_used(FeatureIndex.ANTHROPIC)
- message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
+ try:
+ message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
+ except AgentFrameworkException:
+ raise
+ except Exception as ex:
+ raise _wrap_anthropic_error(ex) from ex
return self._process_message(message, options)
return _get_response()
diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py
index d0b9a4a8f1a..bd9eba7f16b 100644
--- a/python/packages/anthropic/tests/test_anthropic_client.py
+++ b/python/packages/anthropic/tests/test_anthropic_client.py
@@ -5,6 +5,8 @@
from typing import Annotated, Any, cast
from unittest.mock import MagicMock, patch
+import anthropic as anthropic_sdk
+import httpx
import pytest
from agent_framework import (
Agent,
@@ -23,6 +25,11 @@
)
from agent_framework._settings import load_settings
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
+from agent_framework.exceptions import (
+ ChatClientException,
+ ChatClientInvalidAuthException,
+ ChatClientInvalidRequestException,
+)
from agent_framework.observability import ChatTelemetryLayer
from anthropic.types.beta import (
BetaMessage,
@@ -1870,6 +1877,80 @@ async def mock_stream():
assert mock_anthropic_client.beta.messages.create.call_args.kwargs["stream"] is True
+def _anthropic_status_error(
+ error_cls: type[anthropic_sdk.APIStatusError], status_code: int, message: str
+) -> anthropic_sdk.APIStatusError:
+ request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
+ response = httpx.Response(status_code, request=request, json={"error": {"message": message}})
+ return error_cls(message, response=response, body={"error": {"message": message}})
+
+
+@pytest.mark.parametrize(
+ ("sdk_exception", "expected_exception"),
+ [
+ (_anthropic_status_error(anthropic_sdk.AuthenticationError, 401, "boom"), ChatClientInvalidAuthException),
+ (_anthropic_status_error(anthropic_sdk.PermissionDeniedError, 403, "boom"), ChatClientInvalidAuthException),
+ (_anthropic_status_error(anthropic_sdk.BadRequestError, 400, "boom"), ChatClientInvalidRequestException),
+ (_anthropic_status_error(anthropic_sdk.InternalServerError, 500, "boom"), ChatClientException),
+ # Not an anthropic APIError at all (connection reset, timeout, unexpected SDK bug):
+ # must still be wrapped so ``except ChatClientException`` callers never see it raw.
+ (RuntimeError("connection reset"), ChatClientException),
+ ],
+)
+async def test_inner_get_response_wraps_sdk_errors(
+ mock_anthropic_client: MagicMock,
+ sdk_exception: Exception,
+ expected_exception: type[Exception],
+) -> None:
+ """Non-streaming _inner_get_response must translate raw Anthropic SDK errors into
+ the framework's ChatClientException hierarchy, matching every other provider
+ (OpenAI, Mistral, Ollama, Bedrock)."""
+ client = create_test_anthropic_client(mock_anthropic_client)
+ mock_anthropic_client.beta.messages.create.side_effect = sdk_exception
+
+ messages = [Message(role="user", contents=["Hi"])]
+ chat_options = ChatOptions(max_tokens=10)
+
+ with pytest.raises(expected_exception, match="Anthropic"):
+ await client._inner_get_response( # type: ignore[attr-defined]
+ messages=messages, options=chat_options
+ )
+
+
+async def test_inner_get_response_streaming_wraps_sdk_errors(mock_anthropic_client: MagicMock) -> None:
+ """Streaming _inner_get_response must translate raw Anthropic SDK errors into the
+ framework's ChatClientException hierarchy too, both when the create() call fails and
+ when the failure happens partway through iterating the stream."""
+ client = create_test_anthropic_client(mock_anthropic_client)
+ messages = [Message(role="user", contents=["Hi"])]
+ chat_options = ChatOptions(max_tokens=10)
+
+ # 1. Failure raised by the create() call itself.
+ mock_anthropic_client.beta.messages.create.side_effect = _anthropic_status_error(
+ anthropic_sdk.AuthenticationError, 401, "invalid api key"
+ )
+ with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"):
+ async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable]
+ messages=messages, options=chat_options, stream=True
+ ):
+ pass
+
+ # 2. Failure raised mid-stream, after at least one event has been yielded.
+ async def _raise_after_first_event() -> Any:
+ event = MagicMock()
+ event.type = "message_stop"
+ yield event
+ raise _anthropic_status_error(anthropic_sdk.PermissionDeniedError, 403, "permission denied")
+
+ mock_anthropic_client.beta.messages.create.side_effect = None
+ mock_anthropic_client.beta.messages.create.return_value = _raise_after_first_event()
+ with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"):
+ async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable]
+ messages=messages, options=chat_options, stream=True
+ ):
+ pass
+
+
def test_process_stream_event_message_start_sets_assistant_role(mock_anthropic_client: MagicMock) -> None:
"""Test that message_start streaming event sets role='assistant'.
diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md
index 7caacd4d823..c4cdb364316 100644
--- a/python/packages/chatkit/README.md
+++ b/python/packages/chatkit/README.md
@@ -2,7 +2,7 @@
This package provides an integration layer between Microsoft Agent Framework
and [OpenAI ChatKit (Python)](https://github.com/openai/chatkit-python/).
-Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/chatkit-python/blob/main/docs/server.md#agents-sdk-integration), and provides the following helpers:
+Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/chatkit-python/blob/main/docs/quickstart.md#generate-model-responses), and provides the following helpers:
- `stream_agent_response`: A helper to convert a streamed `AgentResponseUpdate`
from a Microsoft Agent Framework agent that implements `SupportsAgentRun` to ChatKit events.
diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py
index c4fe24670f2..88f75598b5b 100644
--- a/python/packages/chatkit/agent_framework_chatkit/_converter.py
+++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py
@@ -81,33 +81,40 @@ async def user_message_to_input(
"""
# Extract text content from the user message
text_content = ""
+ contents: list[Content] = []
+
+ def append_text_content() -> None:
+ nonlocal text_content
+ if stripped_text := text_content.strip():
+ contents.append(Content.from_text(text=stripped_text))
+ text_content = ""
+
if item.content:
for content_part in item.content:
if isinstance(content_part, UserMessageTextContent):
text_content += content_part.text
+ elif isinstance(content_part, UserMessageTagContent):
+ tag_content = self.tag_to_message_content(content_part)
+ if tag_content.type == "text":
+ text_content += tag_content.text or ""
+ else:
+ append_text_content()
+ contents.append(tag_content)
+
+ append_text_content()
- # Convert attachments to Content
- data_contents: list[Content] = []
+ # Append attachments after the ordered message content.
if item.attachments:
for attachment in item.attachments:
content = await self.attachment_to_message_content(attachment)
if content is not None:
- data_contents.append(content)
+ contents.append(content)
# Create the message with text and attachments
- if not text_content.strip() and not data_contents:
+ if not contents:
return None
- # If only text and no attachments, use text parameter for simplicity
- if text_content.strip() and not data_contents:
- user_message = Message(role="user", contents=[text_content.strip()])
- else:
- # Build contents list with both text and attachments
- contents: list[Content] = []
- if text_content.strip():
- contents.append(Content.from_text(text=text_content.strip()))
- contents.extend(data_contents)
- user_message = Message(role="user", contents=contents)
+ user_message = Message(role="user", contents=contents)
# Handle quoted text if this is the last message
messages = [user_message]
diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py
index 730fd6ec6e1..a0fb0244549 100644
--- a/python/packages/chatkit/tests/test_converter.py
+++ b/python/packages/chatkit/tests/test_converter.py
@@ -7,8 +7,8 @@
from unittest.mock import Mock
import pytest
-from agent_framework import Message
-from chatkit.types import InferenceOptions, UserMessageTextContent
+from agent_framework import Content, Message
+from chatkit.types import InferenceOptions, UserMessageTagContent, UserMessageTextContent
from pydantic import AnyUrl
from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input
@@ -110,6 +110,98 @@ async def test_to_agent_input_multiple_content_parts(self, converter):
assert len(result) == 1
assert result[0].text == "Hello world!"
+ async def test_to_agent_input_keeps_tag_inline_with_text(self, converter):
+ """Test converting user message tags in their original text position."""
+ from chatkit.types import UserMessageItem
+
+ input_item = UserMessageItem(
+ id="msg_tag",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="user_message",
+ content=[
+ UserMessageTextContent(text="Ask "),
+ UserMessageTagContent(
+ type="input_tag",
+ id="tag_1",
+ text="john",
+ data={"name": "John Doe"},
+ interactive=False,
+ ),
+ UserMessageTextContent(text=" about the report."),
+ ],
+ attachments=[],
+ inference_options=InferenceOptions(),
+ )
+
+ result = await converter.to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert result[0].text == "Ask Name:John Doe about the report."
+
+ async def test_to_agent_input_keeps_tag_only_message(self, converter):
+ """Test that a user message containing only a tag is not discarded."""
+ from chatkit.types import UserMessageItem
+
+ input_item = UserMessageItem(
+ id="msg_tag_only",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="user_message",
+ content=[
+ UserMessageTagContent(
+ type="input_tag",
+ id="tag_1",
+ text="john",
+ data={"name": "John Doe"},
+ interactive=False,
+ )
+ ],
+ attachments=[],
+ inference_options=InferenceOptions(),
+ )
+
+ result = await converter.to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert result[0].text == "Name:John Doe"
+
+ async def test_to_agent_input_preserves_non_text_tag_position(self):
+ """Test that custom non-text tag conversions remain between adjacent text."""
+ from chatkit.types import UserMessageItem
+
+ class UriTagConverter(ThreadItemConverter):
+ def tag_to_message_content(self, tag: UserMessageTagContent) -> Content:
+ return Content.from_uri(uri=f"https://example.com/users/{tag.text}", media_type="text/html")
+
+ input_item = UserMessageItem(
+ id="msg_uri_tag",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="user_message",
+ content=[
+ UserMessageTextContent(text="Ask"),
+ UserMessageTagContent(
+ type="input_tag",
+ id="tag_1",
+ text="john",
+ data={"name": "John Doe"},
+ interactive=False,
+ ),
+ UserMessageTextContent(text="about the report."),
+ ],
+ attachments=[],
+ inference_options=InferenceOptions(),
+ )
+
+ result = await UriTagConverter().to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert [content.type for content in result[0].contents] == ["text", "uri", "text"]
+ assert result[0].contents[0].text == "Ask"
+ assert result[0].contents[1].uri == "https://example.com/users/john"
+ assert result[0].contents[2].text == "about the report."
+
async def test_to_agent_input_with_quoted_text_for_last_message(self, converter):
"""Test quoted text is prepended as context for the last user message."""
from chatkit.types import UserMessageItem
diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py
index e34f507c07f..bbdc3305a44 100644
--- a/python/packages/claude/agent_framework_claude/_agent.py
+++ b/python/packages/claude/agent_framework_claude/_agent.py
@@ -29,6 +29,7 @@
normalize_messages,
normalize_tools,
)
+from agent_framework._mcp import MCPTool
from agent_framework._telemetry import mark_feature_used
from agent_framework.exceptions import AgentException, AgentInvalidRequestException
from agent_framework.observability import AgentTelemetryLayer
@@ -72,6 +73,15 @@
logger = logging.getLogger("agent_framework.claude")
+_MCP_TOOL_MESSAGE = (
+ "MCP server '{name}' cannot be passed to ClaudeAgent as a tool: the Claude Agent SDK "
+ "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its "
+ "framework behavior. Configure the server natively instead, for example "
+ "default_options={{'mcp_servers': {{'{name}': "
+ "{{'type': 'stdio', 'command': 'python', 'args': ['server.py']}}}}}}, or use a ChatAgent, "
+ "where the framework owns the connection."
+)
+
FINISH_REASON_MAP: dict[str, str] = {
"end_turn": "stop",
"stop_sequence": "stop",
@@ -408,8 +418,9 @@ def _normalize_tools(
return
non_builtin_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] = []
- if not isinstance(tools, list):
- tools = [tools]
+ # Same wrapping rule as normalize_tools: any other Sequence is a collection of tools.
+ if isinstance(tools, (str, bytes, bytearray, Mapping)) or not isinstance(tools, Sequence):
+ tools = [tools] # type: ignore[assignment]
for tool in tools: # type: ignore[reportUnknownVariableType]
if isinstance(tool, str):
self._builtin_tools.append(tool)
@@ -417,7 +428,12 @@ def _normalize_tools(
non_builtin_tools.append(tool) # type: ignore[union-attr, reportUnknownArgumentType]
if not non_builtin_tools:
return
- self._custom_tools.extend(normalize_tools(non_builtin_tools))
+ # Check after normalizing: it flattens tool-collection wrappers, which can hide an MCPTool.
+ normalized = normalize_tools(non_builtin_tools)
+ for tool in normalized:
+ if isinstance(tool, MCPTool):
+ raise TypeError(_MCP_TOOL_MESSAGE.format(name=tool.name))
+ self._custom_tools.extend(normalized)
async def __aenter__(self) -> RawClaudeAgent[OptionsT]:
"""Start the agent when entering async context."""
diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py
index 38359f9d152..6fdb444f825 100644
--- a/python/packages/claude/tests/test_claude_agent.py
+++ b/python/packages/claude/tests/test_claude_agent.py
@@ -1,10 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
+from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool
+from agent_framework import AgentResponseUpdate, AgentSession, Content, MCPStdioTool, Message, tool
from agent_framework._settings import load_settings
from agent_framework.exceptions import AgentInvalidRequestException
@@ -174,6 +175,31 @@ def farewell(name: str) -> str:
agent = ClaudeAgent(tools=[greet, farewell])
assert len(agent._custom_tools) == 2 # type: ignore[reportPrivateUsage]
+ def test_mcp_tool_is_rejected_with_the_native_configuration(self) -> None:
+ """An MCP server cannot keep its framework behavior here, so it is refused, not dropped."""
+ with pytest.raises(TypeError, match="mcp_servers"):
+ ClaudeAgent(tools=[MCPStdioTool(name="weather", command="python")])
+
+ def test_mcp_tool_in_a_tuple_is_rejected(self) -> None:
+ """``tools`` takes any sequence, so a tuple must not slip past the refusal."""
+ with pytest.raises(TypeError, match="mcp_servers"):
+ ClaudeAgent(tools=(MCPStdioTool(name="weather", command="python"),))
+
+ def test_mcp_tool_inside_a_tool_collection_is_rejected(self) -> None:
+ """normalize_tools flattens collection wrappers, so the refusal has to run after it."""
+
+ toolbox = SimpleNamespace(tools=[MCPStdioTool(name="weather", command="python")])
+
+ with pytest.raises(TypeError, match="mcp_servers"):
+ ClaudeAgent(tools=[toolbox])
+
+ def test_builtin_tools_in_a_tuple_are_recognized(self) -> None:
+ """A tuple of built-in names must classify like the list form, not fall through as custom tools."""
+ agent = ClaudeAgent(tools=("Read", "Bash"))
+
+ assert agent._builtin_tools == ["Read", "Bash"] # type: ignore[reportPrivateUsage]
+ assert agent._custom_tools == [] # type: ignore[reportPrivateUsage]
+
def test_no_tools(self) -> None:
"""Test agent without tools."""
agent = ClaudeAgent()
diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md
index 257782f7c34..28c76a601c8 100644
--- a/python/packages/core/AGENTS.md
+++ b/python/packages/core/AGENTS.md
@@ -13,6 +13,7 @@ agent_framework/
âââ _clients.py # Chat client base classes and protocols
âââ _types.py # Core types (Message, ChatResponse, Content, etc.)
âââ _tools.py # Tool definitions and function invocation
+âââ _vectors.py # Vector store models, CRUD/search abstractions, and protocols
âââ _middleware.py # Middleware system for request/response interception
âââ _sessions.py # AgentSession and context provider abstractions
âââ _skills.py # Agent Skills system (models, executors, provider)
@@ -64,6 +65,19 @@ agent_framework/
- **`@tool`** decorator - Converts functions to tools
- **`use_function_invocation()`** - Decorator to add automatic function calling to chat clients
+### Vector stores (`_vectors.py`)
+
+The vector store API is experimental under the shared `VECTOR_STORES` feature ID.
+
+- **`@vectorstoremodel`** - Declares key, data, and vector fields on dataclasses, Pydantic models, and plain classes
+- **`register_vectorstoremodel`** - Registers one definition and msgspec-backed codec pair per model type
+- **`BaseVectorCollection`** - Base class for collection lifecycle and msgspec-backed record CRUD operations;
+ upserts generate embeddings by default and retrieval excludes vectors by default
+- **`BaseVectorStore`** - Base class for stores that create collection clients
+- **`BaseVectorSearch`** - Base class for vector and keyword-hybrid search
+- **`create_vector_search_tool`** - Creates an agent tool from any `SupportsVectorSearch` implementation
+- **`SupportsVectorUpsert`** / **`SupportsVectorSearch`** - Structural protocols for vector store capabilities
+
### Middleware (`_middleware.py`)
- **`AgentMiddleware`** - Intercepts agent `run()` calls
@@ -107,6 +121,7 @@ agent_framework/
- **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing.
- **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries.
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through â but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used.
+- **`header_provider` request scoping** - When sharing an `http_client`, keep provider processing scoped to the originating `MCPStreamableHTTPTool` and strip injected headers from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed connections, and closes framework-created HTTP clients. Caller-owned clients and other tools' hooks remain reusable.
- **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this â it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`.
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
@@ -217,6 +232,17 @@ agent_framework/
hosts remain responsible for authorizing and tenant-scoping access to any shared checkpoint adapter.
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`
+## Evaluation (`_evaluation.py`)
+
+- Core owns provider-neutral evaluation types, local evaluators and checks, `EvalItem` construction, and the
+ `evaluate_agent` / `evaluate_workflow` orchestration functions.
+- Provider packages own their service-specific evaluator implementations and wire serialization. Core evaluation
+ code must not emit a provider's request schema. The deprecated `AgentEvalConverter` remains as a temporary
+ compatibility shim for released Foundry packages whose declared core range still imports it; new code must not use
+ the shim.
+- Core orchestration builds `EvalItem` instances through private helpers; callers needing manual control construct
+ the public `EvalItem` directly.
+
## Built-in Providers
### OpenAI (`openai/`)
diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py
index 77c56fa7559..241bcda3aae 100644
--- a/python/packages/core/agent_framework/__init__.py
+++ b/python/packages/core/agent_framework/__init__.py
@@ -284,6 +284,25 @@
"validate_tool_mode",
"validate_tools",
),
+ "._vectors": (
+ "DISTANCE_FUNCTION_DIRECTION_HELPER",
+ "BaseVectorCollection",
+ "BaseVectorSearch",
+ "BaseVectorStore",
+ "DistanceFunction",
+ "FieldTypes",
+ "IndexKind",
+ "SearchResponse",
+ "SearchResults",
+ "SearchType",
+ "SupportsVectorSearch",
+ "SupportsVectorUpsert",
+ "VectorStoreCollectionDefinition",
+ "VectorStoreField",
+ "create_vector_search_tool",
+ "register_vectorstoremodel",
+ "vectorstoremodel",
+ ),
"._workflows._agent": ("WorkflowAgent",),
"._workflows._agent_executor": ("AgentExecutor", "AgentExecutorRequest", "AgentExecutorResponse"),
"._workflows._agent_utils": ("resolve_agent_id",),
@@ -371,6 +390,7 @@
"DEFAULT_MODE_SOURCE_ID",
"DEFAULT_TODO_SOURCE_ID",
"DEFAULT_TOOL_APPROVAL_SOURCE_ID",
+ "DISTANCE_FUNCTION_DIRECTION_HELPER",
"EXCLUDED_KEY",
"EXCLUDE_REASON_KEY",
"GROUP_ANNOTATION_KEY",
@@ -412,6 +432,9 @@
"BaseAgent",
"BaseChatClient",
"BaseEmbeddingClient",
+ "BaseVectorCollection",
+ "BaseVectorSearch",
+ "BaseVectorStore",
"CachingSkillsSource",
"Case",
"CharacterEstimatorTokenizer",
@@ -438,6 +461,7 @@
"DeduplicatingSkillsSource",
"Default",
"DelegatingSkillsSource",
+ "DistanceFunction",
"Edge",
"EdgeCondition",
"EdgeDuplicationError",
@@ -456,6 +480,7 @@
"ExperimentalFeature",
"FanInEdgeGroup",
"FanOutEdgeGroup",
+ "FieldTypes",
"FileAccessProvider",
"FileCheckpointStorage",
"FileHistoryProvider",
@@ -490,6 +515,7 @@
"InMemoryHistoryProvider",
"InMemorySkillsSource",
"InProcRunnerContext",
+ "IndexKind",
"InlineSkill",
"InlineSkillResource",
"InlineSkillScript",
@@ -527,6 +553,9 @@
"Runner",
"RunnerContext",
"SamplingApprovalCallback",
+ "SearchResponse",
+ "SearchResults",
+ "SearchType",
"SecretString",
"SelectiveToolCallCompactionStrategy",
"ServiceSessionId",
@@ -555,6 +584,8 @@
"SupportsImageGenerationTool",
"SupportsMCPTool",
"SupportsShellTool",
+ "SupportsVectorSearch",
+ "SupportsVectorUpsert",
"SupportsWebSearchTool",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
@@ -580,6 +611,8 @@
"UsageDetails",
"UserInputRequiredException",
"ValidationTypeEnum",
+ "VectorStoreCollectionDefinition",
+ "VectorStoreField",
"Workflow",
"WorkflowAgent",
"WorkflowBuilder",
@@ -613,6 +646,7 @@
"create_always_approve_tool_with_arguments_response",
"create_edge_runner",
"create_harness_agent",
+ "create_vector_search_tool",
"detect_media_type_from_base64",
"enqueue_messages",
"evaluate_agent",
@@ -636,6 +670,7 @@
"prepend_instructions_to_messages",
"register_checkpoint_type",
"register_state_type",
+ "register_vectorstoremodel",
"resolve_agent_id",
"response_handler",
"set_agent_mode",
@@ -650,6 +685,7 @@
"validate_tool_mode",
"validate_tools",
"validate_workflow_graph",
+ "vectorstoremodel",
"workflow",
]
diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi
index fa8f6a75ae6..5816c90600f 100644
--- a/python/packages/core/agent_framework/__init__.pyi
+++ b/python/packages/core/agent_framework/__init__.pyi
@@ -250,6 +250,25 @@ from ._types import (
validate_tool_mode,
validate_tools,
)
+from ._vectors import (
+ DISTANCE_FUNCTION_DIRECTION_HELPER,
+ BaseVectorCollection,
+ BaseVectorSearch,
+ BaseVectorStore,
+ DistanceFunction,
+ FieldTypes,
+ IndexKind,
+ SearchResponse,
+ SearchResults,
+ SearchType,
+ SupportsVectorSearch,
+ SupportsVectorUpsert,
+ VectorStoreCollectionDefinition,
+ VectorStoreField,
+ create_vector_search_tool,
+ register_vectorstoremodel,
+ vectorstoremodel,
+)
from ._workflows._agent import WorkflowAgent
from ._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from ._workflows._agent_utils import resolve_agent_id
@@ -335,6 +354,7 @@ __all__ = [
"DEFAULT_MODE_SOURCE_ID",
"DEFAULT_TODO_SOURCE_ID",
"DEFAULT_TOOL_APPROVAL_SOURCE_ID",
+ "DISTANCE_FUNCTION_DIRECTION_HELPER",
"EXCLUDED_KEY",
"EXCLUDE_REASON_KEY",
"GROUP_ANNOTATION_KEY",
@@ -376,6 +396,9 @@ __all__ = [
"BaseAgent",
"BaseChatClient",
"BaseEmbeddingClient",
+ "BaseVectorCollection",
+ "BaseVectorSearch",
+ "BaseVectorStore",
"CachingSkillsSource",
"Case",
"CharacterEstimatorTokenizer",
@@ -402,6 +425,7 @@ __all__ = [
"DeduplicatingSkillsSource",
"Default",
"DelegatingSkillsSource",
+ "DistanceFunction",
"Edge",
"EdgeCondition",
"EdgeDuplicationError",
@@ -420,6 +444,7 @@ __all__ = [
"ExperimentalFeature",
"FanInEdgeGroup",
"FanOutEdgeGroup",
+ "FieldTypes",
"FileAccessProvider",
"FileCheckpointStorage",
"FileHistoryProvider",
@@ -454,6 +479,7 @@ __all__ = [
"InMemoryHistoryProvider",
"InMemorySkillsSource",
"InProcRunnerContext",
+ "IndexKind",
"InlineSkill",
"InlineSkillResource",
"InlineSkillScript",
@@ -491,6 +517,9 @@ __all__ = [
"Runner",
"RunnerContext",
"SamplingApprovalCallback",
+ "SearchResponse",
+ "SearchResults",
+ "SearchType",
"SecretString",
"SelectiveToolCallCompactionStrategy",
"ServiceSessionId",
@@ -519,6 +548,8 @@ __all__ = [
"SupportsImageGenerationTool",
"SupportsMCPTool",
"SupportsShellTool",
+ "SupportsVectorSearch",
+ "SupportsVectorUpsert",
"SupportsWebSearchTool",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
@@ -544,6 +575,8 @@ __all__ = [
"UsageDetails",
"UserInputRequiredException",
"ValidationTypeEnum",
+ "VectorStoreCollectionDefinition",
+ "VectorStoreField",
"Workflow",
"WorkflowAgent",
"WorkflowBuilder",
@@ -577,6 +610,7 @@ __all__ = [
"create_always_approve_tool_with_arguments_response",
"create_edge_runner",
"create_harness_agent",
+ "create_vector_search_tool",
"detect_media_type_from_base64",
"enqueue_messages",
"evaluate_agent",
@@ -600,6 +634,7 @@ __all__ = [
"prepend_instructions_to_messages",
"register_checkpoint_type",
"register_state_type",
+ "register_vectorstoremodel",
"resolve_agent_id",
"response_handler",
"set_agent_mode",
@@ -614,5 +649,6 @@ __all__ = [
"validate_tool_mode",
"validate_tools",
"validate_workflow_graph",
+ "vectorstoremodel",
"workflow",
]
diff --git a/python/packages/core/agent_framework/_evaluation.py b/python/packages/core/agent_framework/_evaluation.py
index ab71d84a15c..327258b846b 100644
--- a/python/packages/core/agent_framework/_evaluation.py
+++ b/python/packages/core/agent_framework/_evaluation.py
@@ -39,6 +39,7 @@
import inspect
import json
import logging
+import warnings
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from enum import Enum
@@ -54,7 +55,7 @@
)
from ._feature_stage import ExperimentalFeature, experimental
-from ._tools import FunctionTool
+from ._tools import FunctionTool, normalize_tools
from ._types import AgentResponse, Message
if TYPE_CHECKING:
@@ -726,157 +727,121 @@ async def evaluate(
# endregion
-# region Converter
+
+def _warn_agent_eval_converter_deprecated() -> None:
+ """Warn when the legacy evaluation converter compatibility surface is used."""
+ warnings.warn(
+ "`AgentEvalConverter` is deprecated and will be removed in a future version. "
+ "Construct `EvalItem` directly or use `evaluate_agent()` / `evaluate_workflow()`; "
+ "Foundry wire conversion is internal to `agent-framework-foundry`.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+
+
+def _convert_legacy_foundry_message(message: Message) -> list[dict[str, Any]]:
+ """Preserve the legacy Foundry wire conversion for package compatibility."""
+ content_items: list[dict[str, Any]] = []
+ tool_results: list[dict[str, Any]] = []
+
+ for content in message.contents or []:
+ if content.type == "text" and content.text:
+ content_items.append({"type": "text", "text": content.text})
+ elif content.type in ("data", "uri") and content.uri:
+ image: dict[str, Any] = {
+ "type": "input_image",
+ "image_url": content.uri,
+ }
+ if content.media_type:
+ image["detail"] = "auto"
+ content_items.append(image)
+ elif content.type == "function_call":
+ arguments = content.arguments
+ if isinstance(arguments, str):
+ try:
+ arguments = json.loads(arguments)
+ except (json.JSONDecodeError, TypeError):
+ arguments = {"_raw_arguments": "[unparseable]"}
+ content_items.append({
+ "type": "tool_call",
+ "tool_call_id": content.call_id or "",
+ "name": content.name or "",
+ "arguments": arguments if arguments is not None else {},
+ })
+ elif content.type == "function_result":
+ result = content.result
+ if isinstance(result, str):
+ with contextlib.suppress(json.JSONDecodeError, TypeError):
+ result = json.loads(result)
+ tool_results.append({
+ "call_id": content.call_id or "",
+ "result": result,
+ })
+
+ if tool_results:
+ return [
+ {
+ "role": "tool",
+ "tool_call_id": tool_result["call_id"],
+ "content": [{"type": "tool_result", "tool_result": tool_result["result"]}],
+ }
+ for tool_result in tool_results
+ ]
+ if content_items:
+ return [{"role": message.role, "content": content_items}]
+ return [
+ {
+ "role": message.role,
+ "content": [{"type": "text", "text": ""}],
+ }
+ ]
@experimental(feature_id=ExperimentalFeature.EVALS)
class AgentEvalConverter:
- """Converts agent-framework types to evaluation format.
+ """Deprecated compatibility surface for earlier Agent Framework releases.
- Handles the type gap between agent-framework's ``Message`` / ``Content`` /
- ``FunctionTool`` types and the OpenAI-style agent message schema used by
- evaluation providers. All methods are static â no instantiation needed.
+ New code should construct :class:`EvalItem` directly or use
+ :func:`evaluate_agent` / :func:`evaluate_workflow`. Foundry-specific wire
+ serialization is owned by ``agent-framework-foundry``.
"""
@staticmethod
def convert_message(message: Message) -> list[dict[str, Any]]:
- """Convert a single ``Message`` to Foundry agent evaluator format.
-
- Uses typed content lists as required by Foundry evaluators:
-
- .. code-block:: python
-
- {"role": "assistant", "content": [{"type": "tool_call", ...}]}
- {"role": "user", "content": [{"type": "input_image", ...}]}
-
- Supported content types:
-
- * ``text`` â ``{"type": "text", "text": ...}``
- * ``data`` / ``uri`` (images) â ``{"type": "input_image", "image_url": ...}``
- * ``function_call`` â ``{"type": "tool_call", ...}``
- * ``function_result`` â ``{"type": "tool_result", ...}``
-
- A single agent-framework ``Message`` with multiple ``function_result``
- contents produces multiple output messages (one per tool result).
-
- Args:
- message: An agent-framework ``Message``.
-
- Returns:
- A list of Foundry-format message dicts.
- """
- role = message.role
- contents = message.contents or []
-
- content_items: list[dict[str, Any]] = []
- tool_results: list[dict[str, Any]] = []
-
- for c in contents:
- if c.type == "text" and c.text:
- content_items.append({"type": "text", "text": c.text})
- elif c.type in ("data", "uri") and c.uri:
- # Image / media content â OpenAI input_image format
- img: dict[str, Any] = {
- "type": "input_image",
- "image_url": c.uri,
- }
- if c.media_type:
- img["detail"] = "auto"
- content_items.append(img)
- elif c.type == "function_call":
- args = c.arguments
- if isinstance(args, str):
- try:
- args = json.loads(args)
- except (json.JSONDecodeError, TypeError):
- # Sanitize to avoid leaking sensitive tool-call arguments
- # to external evaluation services.
- args = {"_raw_arguments": "[unparseable]"}
- tc: dict[str, Any] = {
- "type": "tool_call",
- "tool_call_id": c.call_id or "",
- "name": c.name or "",
- }
- tc["arguments"] = args if args is not None else {}
- content_items.append(tc)
- elif c.type == "function_result":
- result_val = c.result
- if isinstance(result_val, str):
- with contextlib.suppress(json.JSONDecodeError, TypeError):
- result_val = json.loads(result_val)
- tool_results.append({
- "call_id": c.call_id or "",
- "result": result_val,
- })
-
- output: list[dict[str, Any]] = []
-
- if tool_results:
- for tr in tool_results:
- output.append({
- "role": "tool",
- "tool_call_id": tr["call_id"],
- "content": [{"type": "tool_result", "tool_result": tr["result"]}],
- })
- elif content_items:
- output.append({"role": role, "content": content_items})
- else:
- output.append({
- "role": role,
- "content": [{"type": "text", "text": ""}],
- })
-
- return output
+ """Convert one message using the legacy Foundry evaluator wire format."""
+ _warn_agent_eval_converter_deprecated()
+ return _convert_legacy_foundry_message(message)
@staticmethod
def convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
- """Convert a sequence of ``Message`` objects to Foundry evaluator format.
-
- Args:
- messages: Agent-framework messages.
-
- Returns:
- A list of Foundry-format message dicts with typed content lists.
- """
- result: list[dict[str, Any]] = []
- for msg in messages:
- result.extend(AgentEvalConverter.convert_message(msg))
- return result
+ """Convert messages using the legacy Foundry evaluator wire format."""
+ _warn_agent_eval_converter_deprecated()
+ return [converted for message in messages for converted in _convert_legacy_foundry_message(message)]
@staticmethod
def extract_tools(agent: Any) -> list[dict[str, Any]]:
- """Extract tool definitions from an agent instance.
-
- Reads ``agent.default_options["tools"]`` and ``agent.mcp_tools``
- and converts each ``FunctionTool`` to ``{name, description, parameters}``.
-
- Args:
- agent: An agent-framework agent instance.
-
- Returns:
- A list of tool definition dicts.
- """
+ """Extract legacy evaluator tool-definition dictionaries from an agent."""
+ _warn_agent_eval_converter_deprecated()
tools: list[dict[str, Any]] = []
seen: set[str] = set()
raw_tools = getattr(agent, "default_options", {}).get("tools", [])
- for t in raw_tools:
- if isinstance(t, FunctionTool) and t.name not in seen:
+ for tool in raw_tools:
+ if isinstance(tool, FunctionTool) and tool.name not in seen:
tools.append({
- "name": t.name,
- "description": t.description,
- "parameters": t.parameters(),
+ "name": tool.name,
+ "description": tool.description,
+ "parameters": tool.parameters(),
})
- seen.add(t.name)
- # Include tools from connected MCP servers
+ seen.add(tool.name)
for mcp in getattr(agent, "mcp_tools", []):
- for t in getattr(mcp, "functions", []):
- if isinstance(t, FunctionTool) and t.name not in seen:
+ for tool in getattr(mcp, "functions", []):
+ if isinstance(tool, FunctionTool) and tool.name not in seen:
tools.append({
- "name": t.name,
- "description": t.description,
- "parameters": t.parameters(),
+ "name": tool.name,
+ "description": tool.description,
+ "parameters": tool.parameters(),
})
- seen.add(t.name)
+ seen.add(tool.name)
return tools
@staticmethod
@@ -885,47 +850,46 @@ def to_eval_item(
query: str | Sequence[Message],
response: AgentResponse[Any],
agent: Any | None = None,
- tools: Sequence[FunctionTool] | None = None,
+ tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
context: str | None = None,
) -> EvalItem:
- """Convert a complete agent interaction to an ``EvalItem``.
+ """Build an ``EvalItem`` through the provider-neutral compatibility path."""
+ _warn_agent_eval_converter_deprecated()
+ return _to_eval_item(query=query, response=response, agent=agent, tools=tools, context=context)
- Args:
- query: The user query string, or input messages.
- response: The agent's response.
- agent: Optional agent instance to auto-extract tool definitions.
- tools: Explicit tool list (takes precedence over *agent*).
- context: Optional context document for groundedness evaluation.
-
- Returns:
- An ``EvalItem`` suitable for passing to any ``Evaluator``.
- """
- input_msgs = [Message("user", [query])] if isinstance(query, str) else list(query)
-
- all_msgs = list(input_msgs) + list(response.messages or [])
-
- typed_tools: list[FunctionTool] = []
- if tools:
- typed_tools = list(tools)
- elif agent:
- raw_tools = getattr(agent, "default_options", {}).get("tools", [])
- typed_tools = [t for t in raw_tools if isinstance(t, FunctionTool)]
- # Include tools from connected MCP servers
- seen = {t.name for t in typed_tools}
- for mcp in getattr(agent, "mcp_tools", []):
- for t in getattr(mcp, "functions", []):
- if isinstance(t, FunctionTool) and t.name not in seen:
- typed_tools.append(t)
- seen.add(t.name)
-
- return EvalItem(
- conversation=all_msgs,
- tools=typed_tools or None,
- context=context,
- )
+@experimental(feature_id=ExperimentalFeature.EVALS)
+def _to_eval_item(
+ *,
+ query: str | Sequence[Message],
+ response: AgentResponse[Any],
+ agent: Any | None = None,
+ tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
+ context: str | None = None,
+) -> EvalItem:
+ """Build a provider-neutral ``EvalItem`` from an agent interaction."""
+ input_msgs = [Message("user", [query])] if isinstance(query, str) else list(query)
+ all_msgs = list(input_msgs) + list(response.messages or [])
+
+ typed_tools: list[FunctionTool] = []
+ if tools:
+ typed_tools = [tool for tool in normalize_tools(tools) if isinstance(tool, FunctionTool)]
+ elif agent:
+ raw_tools = getattr(agent, "default_options", {}).get("tools", [])
+ typed_tools = [tool for tool in normalize_tools(raw_tools) if isinstance(tool, FunctionTool)]
+ seen = {tool.name for tool in typed_tools}
+ for mcp in getattr(agent, "mcp_tools", []):
+ for tool in getattr(mcp, "functions", []):
+ if isinstance(tool, FunctionTool) and tool.name not in seen:
+ typed_tools.append(tool)
+ seen.add(tool.name)
+
+ return EvalItem(
+ conversation=all_msgs,
+ tools=typed_tools or None,
+ context=context,
+ )
-# endregion
# region Workflow extraction helpers
@@ -1772,7 +1736,7 @@ async def evaluate_agent(
raise ValueError(f"Got {len(query_list)} queries but {len(resp_list)} responses.")
for q, r in zip(query_list, resp_list):
items.append(
- AgentEvalConverter.to_eval_item(
+ _to_eval_item(
query=q,
response=r,
agent=agent,
@@ -1792,7 +1756,7 @@ async def evaluate_agent(
for query in queries:
response = await agent.run([Message("user", [query])])
items.append(
- AgentEvalConverter.to_eval_item(
+ _to_eval_item(
query=query,
response=response,
agent=agent,
@@ -1962,7 +1926,7 @@ async def evaluate_workflow(
agent_items_by_id: dict[str, list[EvalItem]] = {}
for executor_id, agent_data_list in agents_by_id.items():
agent_items_by_id[executor_id] = [
- AgentEvalConverter.to_eval_item(
+ _to_eval_item(
query=ad["query"],
response=ad["response"],
agent=ad["agent"],
@@ -2051,7 +2015,7 @@ def _build_overall_item(
messages=[Message("assistant", [str(final_output)])] # type: ignore[reportUnknownArgumentType]
)
- return AgentEvalConverter.to_eval_item(query=query, response=overall_response)
+ return _to_eval_item(query=query, response=overall_response)
def _resolve_evaluators(
diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py
index 53893e397f3..9f694f6423a 100644
--- a/python/packages/core/agent_framework/_feature_stage.py
+++ b/python/packages/core/agent_framework/_feature_stage.py
@@ -64,6 +64,7 @@ class ExperimentalFeature(str, Enum):
PROGRESSIVE_TOOLS = "PROGRESSIVE_TOOLS"
SESSION_STORE = "SESSION_STORE"
TO_PROMPT_AGENT = "TO_PROMPT_AGENT"
+ VECTOR_STORES = "VECTOR_STORES"
class ReleaseCandidateFeature(str, Enum):
diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py
index 399637dfcef..c0ec3df393a 100644
--- a/python/packages/core/agent_framework/_mcp.py
+++ b/python/packages/core/agent_framework/_mcp.py
@@ -124,9 +124,43 @@ class MCPSpecificApproval(TypedDict, total=False):
"_meta",
})
_mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("_mcp_call_headers")
+_MCP_HEADER_OWNER_EXTENSION = "agent_framework.mcp_header_owner"
+_MCP_INJECTED_HEADER_KEYS_EXTENSION = "agent_framework.mcp_injected_header_keys"
MCP_DEFAULT_TIMEOUT = 30
MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5
+
+class _MCPHeaderScopedClient:
+ """Attach private tool context to MCP transport requests."""
+
+ def __init__(self, client: AsyncClient, owner: object) -> None:
+ self._client = client
+ self._owner = owner
+
+ def __getattr__(self, name: str) -> Any:
+ # Delegate the rest of the httpx client surface so this wrapper stays a
+ # drop-in for the MCP transport. Only the request-sending methods below
+ # are wrapped; anything the transport reads (timeouts, headers, ...)
+ # comes straight from the caller's client. ``_client`` itself is always a
+ # real instance attribute, so guard against recursing on a partially
+ # initialized wrapper.
+ if name == "_client":
+ raise AttributeError(name)
+ return getattr(self._client, name)
+
+ def _tagged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
+ extensions = dict(kwargs.get("extensions") or {})
+ extensions[_MCP_HEADER_OWNER_EXTENSION] = self._owner
+ kwargs["extensions"] = extensions
+ return kwargs
+
+ def stream(self, *args: Any, **kwargs: Any) -> Any:
+ return self._client.stream(*args, **self._tagged_kwargs(kwargs))
+
+ async def delete(self, *args: Any, **kwargs: Any) -> Any:
+ return await self._client.delete(*args, **self._tagged_kwargs(kwargs))
+
+
# Default safety limits applied to server-initiated MCP sampling requests
# (``sampling/createMessage``). MCP servers are untrusted third parties, so the
# default ``sampling_callback`` denies requests unless an approval callback is
@@ -3043,7 +3077,7 @@ def __init__(
Note:
The arguments are used to create a streamable HTTP client using the
new ``mcp.client.streamable_http.streamable_http_client`` API.
- If an asyncClient is provided via ``http_client``, it will be used directly.
+ If an asyncClient is provided via ``http_client``, it will be used as the underlying transport client.
Otherwise, the ``streamable_http_client`` API will create and manage a default client.
Args:
@@ -3120,9 +3154,12 @@ def __init__(
agent middleware) without creating a separate ``httpx.AsyncClient``.
The framework attaches these headers only to requests whose origin (scheme,
host, port) matches the configured ``url``, so they are not leaked to other
- origins on cross-origin redirects. If you instead supply sensitive headers
+ origins on cross-origin redirects; headers injected this way are also removed
+ again if a redirect leaves that origin. If you instead supply sensitive headers
through a custom ``http_client``, you must enforce this same origin-scoped
policy yourself.
+ Headers returned by the provider are applied only to requests issued by this
+ tool, including when several tools share one ``http_client``.
Note that the provider reads these kwargs without consuming them: the same
values continue on to the outbound argument filter, so reading a credential
here does not withhold it from the server. See
@@ -3190,6 +3227,8 @@ def __init__(
# otherwise overwrite each other's snapshot and attach the wrong per-call headers.
self._active_call_headers: dict[str, str] | None = None
self._call_headers_lock = asyncio.Lock()
+ self._header_request_owner = object()
+ self._header_hook_client: AsyncClient | None = None
def _mcp_base_span_attributes(self) -> dict[str, Any]:
attrs = super()._mcp_base_span_attributes()
@@ -3226,11 +3265,17 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT),
)
self._httpx_client = http_client
+ self._exit_stack.push_async_callback(self._close_owned_http_client, http_client)
if not hasattr(self, "_inject_headers_hook"):
async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async]
+ request_owner = request.extensions.get(_MCP_HEADER_OWNER_EXTENSION)
+ if request_owner is not self._header_request_owner:
+ return
if _url_origin(request.url) != target_origin:
+ for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()):
+ request.headers.pop(key, None)
return
# The transport may send this request from a task whose context was
# captured before call_tool set the ContextVar; fall back to the
@@ -3264,18 +3309,59 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async
exc_info=True,
)
headers = {}
+ for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()):
+ request.headers.pop(key, None)
for key, value in headers.items():
request.headers[key] = value
+ request.extensions[_MCP_INJECTED_HEADER_KEYS_EXTENSION] = tuple(headers)
self._inject_headers_hook = _inject_headers
+
+ if self._header_hook_client is not http_client:
+ self._remove_header_hook()
+ self._header_hook_client = http_client
+ if self._inject_headers_hook not in http_client.event_hooks["request"]:
http_client.event_hooks["request"].append(self._inject_headers_hook)
+ # Register before transport entry so failed connections clean up too,
+ # while successful sessions keep the hook through transport shutdown.
+ self._exit_stack.callback(self._remove_header_hook)
+
+ transport_http_client = (
+ _MCPHeaderScopedClient(http_client, self._header_request_owner) if http_client is not None else None
+ )
return streamable_http_client(
url=self.url,
- http_client=http_client,
+ http_client=transport_http_client,
terminate_on_close=self.terminate_on_close if self.terminate_on_close is not None else True,
)
+ async def _close_owned_http_client(self, http_client: AsyncClient) -> None:
+ """Release a framework-created client without retaining it for reconnect."""
+ try:
+ await http_client.aclose()
+ finally:
+ if self._httpx_client is http_client:
+ self._httpx_client = None
+
+ def _remove_header_hook(self) -> None:
+ """Detach this tool's request hook from its HTTP client."""
+ if self._header_hook_client is None or not hasattr(self, "_inject_headers_hook"):
+ return
+ request_hooks = self._header_hook_client.event_hooks["request"]
+ if self._inject_headers_hook in request_hooks:
+ self._header_hook_client.event_hooks["request"] = [
+ hook for hook in request_hooks if hook is not self._inject_headers_hook
+ ]
+ self._header_hook_client = None
+
+ async def _close_on_owner(self) -> None:
+ """Disconnect on the lifecycle owner before removing the request hook."""
+ try:
+ await super()._close_on_owner()
+ finally:
+ self._remove_header_hook()
+
async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]:
"""Call a tool, injecting headers from the header_provider if configured.
diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py
index 384fa05a5be..3751688f91c 100644
--- a/python/packages/core/agent_framework/_serialization.py
+++ b/python/packages/core/agent_framework/_serialization.py
@@ -9,9 +9,11 @@
import re
from collections.abc import Mapping, MutableMapping
from dataclasses import asdict, is_dataclass
-from datetime import date, datetime
+from datetime import date, datetime, time
from functools import lru_cache
-from typing import Any, ClassVar, Protocol, TypeGuard, TypeVar, cast, runtime_checkable
+from typing import Any, ClassVar, Final, Protocol, TypeGuard, TypeVar, cast, runtime_checkable
+
+from typing_extensions import Sentinel
logger = logging.getLogger("agent_framework")
@@ -19,6 +21,7 @@
ProtocolT = TypeVar("ProtocolT", bound="SerializationProtocol")
_JSON_SCALAR_TYPES = (str, int, float, bool, type(None))
_DIRECT_JSON_TYPES = (*_JSON_SCALAR_TYPES, list, dict)
+_SKIP_SERIALIZATION: Final = Sentinel("SKIP_SERIALIZATION")
# Regex pattern for converting CamelCase to snake_case
_CAMEL_TO_SNAKE_PATTERN = re.compile(r"(? TypeGuard[SerializationProtocol]:
return callable(getattr(value, "to_dict", None)) and callable(getattr(value, "from_dict", None))
+def _serialize_value(
+ value: Any,
+ *,
+ exclude: set[str] | None,
+ exclude_none: bool,
+ attribute_name: str,
+ active_container_ids: set[int] | None = None,
+ stringify_dict_keys: bool = False,
+) -> Any:
+ """Recursively serialize a value while preserving skip semantics."""
+ if active_container_ids is None:
+ active_container_ids = set()
+ if type(value) in _JSON_SCALAR_TYPES:
+ return value
+ if _is_serialization_protocol(value):
+ serialized = value.to_dict(exclude=exclude, exclude_none=exclude_none)
+ return _serialize_value(
+ serialized,
+ exclude=exclude,
+ exclude_none=exclude_none,
+ attribute_name=attribute_name,
+ active_container_ids=active_container_ids,
+ )
+ if isinstance(value, list):
+ value_as_list = cast(list[Any], value)
+ container_id = id(value_as_list)
+ if container_id in active_container_ids:
+ raise ValueError("Circular reference detected")
+ active_container_ids.add(container_id)
+ try:
+ serialized_list: list[Any] = []
+ for item in value_as_list:
+ serialized = _serialize_value(
+ item,
+ exclude=exclude,
+ exclude_none=exclude_none,
+ attribute_name=attribute_name,
+ active_container_ids=active_container_ids,
+ )
+ if serialized is not _SKIP_SERIALIZATION:
+ serialized_list.append(serialized)
+ return serialized_list
+ finally:
+ active_container_ids.remove(container_id)
+ if isinstance(value, dict):
+ value_as_dict = cast(dict[Any, Any], value)
+ container_id = id(value_as_dict)
+ if container_id in active_container_ids:
+ raise ValueError("Circular reference detected")
+ active_container_ids.add(container_id)
+ try:
+ serialized_dict: dict[Any, Any] = {}
+ for raw_key, item in value_as_dict.items():
+ dict_key = str(raw_key) if stringify_dict_keys else raw_key
+ if isinstance(item, (datetime, date, time)):
+ serialized_dict[dict_key] = str(item)
+ continue
+ serialized = _serialize_value(
+ item,
+ exclude=exclude,
+ exclude_none=exclude_none,
+ attribute_name=attribute_name,
+ active_container_ids=active_container_ids,
+ )
+ if serialized is not _SKIP_SERIALIZATION:
+ serialized_dict[dict_key] = serialized
+ return serialized_dict
+ finally:
+ active_container_ids.remove(container_id)
+ if is_serializable(value):
+ return value
+ logger.debug(f"Skipping non-serializable value in attribute '{attribute_name}' of type {type(value).__name__}")
+ return _SKIP_SERIALIZATION
+
+
+def _iter_instance_fields(instance: Any) -> dict[str, Any]:
+ """Return attributes stored in ``__dict__`` or slots."""
+ fields = dict(getattr(instance, "__dict__", {}))
+ for cls in type(instance).__mro__:
+ slots = cls.__dict__.get("__slots__", ())
+ if isinstance(slots, str):
+ slots = (slots,)
+ for field_name in slots:
+ if field_name not in {"__dict__", "__weakref__"} and hasattr(instance, field_name):
+ fields[field_name] = getattr(instance, field_name)
+ return fields
+
+
+def get_pickle_state(instance: Any, omitted_fields: set[str]) -> dict[str, Any]:
+ """Build pickle state while omitting runtime-only fields."""
+ state = _iter_instance_fields(instance)
+ for field_name in omitted_fields:
+ state.pop(field_name, None)
+ return state
+
+
+def restore_pickle_state(
+ instance: Any,
+ state: dict[str, Any] | tuple[dict[str, Any], dict[str, Any]],
+ omitted_fields: set[str],
+) -> None:
+ """Restore dict- and slot-backed pickle state."""
+ if isinstance(state, tuple):
+ dict_state, slot_state = state
+ state = {**dict_state, **slot_state}
+ for field_name, value in state.items():
+ object.__setattr__(instance, field_name, value)
+ for field_name in omitted_fields:
+ if field_name in _iter_instance_fields(instance) or hasattr(instance, "__dict__"):
+ object.__setattr__(instance, field_name, None)
+
+
class SerializationMixin:
"""Mixin class providing comprehensive serialization and deserialization capabilities.
@@ -284,6 +399,15 @@ def __init__(self, **kwargs):
DEFAULT_EXCLUDE: ClassVar[set[str]] = set()
INJECTABLE: ClassVar[set[str]] = set()
_SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"}
+ _PICKLE_OMIT_FIELDS: ClassVar[set[str]] = {"raw_representation"}
+
+ def __copy__(self) -> SerializationMixin:
+ """Create a shallow copy without invoking pickle state hooks."""
+ cls = type(self)
+ result = cls.__new__(cls)
+ for field_name, value in _iter_instance_fields(self).items():
+ object.__setattr__(result, field_name, value)
+ return result
def __deepcopy__(self, memo: dict[int, Any]) -> SerializationMixin:
"""Create a deep copy, preserving ``_SHALLOW_COPY_FIELDS`` by reference.
@@ -296,13 +420,21 @@ def __deepcopy__(self, memo: dict[int, Any]) -> SerializationMixin:
cls = type(self)
result = cls.__new__(cls)
memo[id(self)] = result
- for k, v in self.__dict__.items():
+ for k, v in _iter_instance_fields(self).items():
if k in cls._SHALLOW_COPY_FIELDS:
object.__setattr__(result, k, v)
else:
object.__setattr__(result, k, copy.deepcopy(v, memo))
return result
+ def __getstate__(self) -> dict[str, Any]:
+ """Return pickle state without runtime-only shallow-copy fields."""
+ return get_pickle_state(self, self._PICKLE_OMIT_FIELDS)
+
+ def __setstate__(self, state: dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]) -> None:
+ """Restore pickle state and reset runtime-only shallow-copy fields."""
+ restore_pickle_state(self, state, self._PICKLE_OMIT_FIELDS)
+
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
"""Convert the instance and any nested objects to a dictionary.
@@ -335,63 +467,15 @@ def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True)
if key not in combined_exclude and not key.startswith("_"):
if exclude_none and value is None:
continue
- if type(value) in _JSON_SCALAR_TYPES:
- result[key] = value
- continue
- # Recursively serialize SerializationProtocol objects
- if _is_serialization_protocol(value):
- result[key] = value.to_dict(exclude=exclude, exclude_none=exclude_none)
- continue
- # Handle lists containing SerializationProtocol objects
- if isinstance(value, list):
- value_as_list: list[Any] = []
- for item in cast(list[Any], value):
- if type(item) in _JSON_SCALAR_TYPES:
- value_as_list.append(item)
- continue
- if _is_serialization_protocol(item):
- value_as_list.append(item.to_dict(exclude=exclude, exclude_none=exclude_none))
- continue
- if is_serializable(item):
- value_as_list.append(item)
- continue
- logger.debug(
- f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}"
- )
- result[key] = value_as_list
- continue
- # Handle dicts containing SerializationProtocol values
- if isinstance(value, dict):
- from datetime import date, datetime, time
-
- serialized_dict: dict[str, Any] = {}
- for raw_key, v in cast(dict[Any, Any], value).items():
- dict_key = str(raw_key)
- # Convert datetime objects to strings
- if isinstance(v, (datetime, date, time)):
- serialized_dict[dict_key] = str(v)
- continue
- if type(v) in _JSON_SCALAR_TYPES:
- serialized_dict[dict_key] = v
- continue
- if _is_serialization_protocol(v):
- serialized_dict[dict_key] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
- continue
- # Check if the value is JSON serializable
- if is_serializable(v):
- serialized_dict[dict_key] = v
- continue
- logger.debug(
- f"Skipping non-serializable value for key '{dict_key}' in dict attribute '{key}' "
- f"of type {type(v).__name__}"
- )
- result[key] = serialized_dict
- continue
- # Directly include JSON serializable values
- if is_serializable(value):
- result[key] = value
- continue
- logger.debug(f"Skipping non-serializable attribute '{key}' of type {type(value).__name__}")
+ serialized = _serialize_value(
+ value,
+ exclude=exclude,
+ exclude_none=exclude_none,
+ attribute_name=key,
+ stringify_dict_keys=isinstance(value, dict),
+ )
+ if serialized is not _SKIP_SERIALIZATION:
+ result[key] = serialized
return result
diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py
index 955e276fe2d..d20f31b86cf 100644
--- a/python/packages/core/agent_framework/_telemetry.py
+++ b/python/packages/core/agent_framework/_telemetry.py
@@ -55,6 +55,7 @@ class FeatureIndex(IntEnum):
CORE_MCP_SKILLS_SOURCE = 16
CORE_SESSION_STORE = 17
CORE_AGENT_HOOKS = 18
+ CORE_VECTOR_STORES = 19
# This environment variable is reserved by the Foundry hosting environment to
diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py
index 9134dc6a046..e4fadb00a37 100644
--- a/python/packages/core/agent_framework/_types.py
+++ b/python/packages/core/agent_framework/_types.py
@@ -30,7 +30,7 @@
from typing_extensions import TypedDict
from ._feature_stage import ExperimentalFeature, experimental
-from ._serialization import SerializationMixin
+from ._serialization import SerializationMixin, get_pickle_state, restore_pickle_state
from .exceptions import AdditionItemMismatch, ContentError
if sys.version_info >= (3, 13):
@@ -483,6 +483,7 @@ class Content:
"""
_SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"}
+ _PICKLE_OMIT_FIELDS: ClassVar[set[str]] = {"raw_representation"}
def __init__(
self,
@@ -610,6 +611,28 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Content:
object.__setattr__(result, k, deepcopy(v, memo))
return result
+ def __copy__(self) -> Content:
+ """Create a shallow copy while preserving provider runtime fields."""
+ cls = type(self)
+ result = cls.__new__(cls)
+ for field_name, value in self.__dict__.items():
+ object.__setattr__(result, field_name, value)
+ return result
+
+ def __getstate__(self) -> dict[str, Any]:
+ """Return pickle state without runtime-only shallow-copy fields."""
+ state = get_pickle_state(self, self._PICKLE_OMIT_FIELDS)
+ if self.annotations is not None:
+ state["annotations"] = [
+ {key: value for key, value in annotation.items() if key != "raw_representation"}
+ for annotation in self.annotations
+ ]
+ return state
+
+ def __setstate__(self, state: dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]) -> None:
+ """Restore pickle state and reset runtime-only shallow-copy fields."""
+ restore_pickle_state(self, state, self._PICKLE_OMIT_FIELDS)
+
@classmethod
def from_text(
cls: type[ContentT],
diff --git a/python/packages/core/agent_framework/_vectors.py b/python/packages/core/agent_framework/_vectors.py
new file mode 100644
index 00000000000..761cc3a5bb2
--- /dev/null
+++ b/python/packages/core/agent_framework/_vectors.py
@@ -0,0 +1,1923 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Core vector store abstractions."""
+
+from __future__ import annotations
+
+import operator
+from abc import ABC, abstractmethod
+from ast import AST, Lambda, NodeVisitor, expr, parse
+from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping, Sequence
+from dataclasses import dataclass, is_dataclass, replace
+from inspect import Parameter, getsource, signature
+from types import UnionType
+from typing import (
+ Annotated,
+ Any,
+ ClassVar,
+ Final,
+ Generic,
+ Literal,
+ Protocol,
+ TypeAlias,
+ TypeGuard,
+ Union,
+ cast,
+ get_args,
+ get_origin,
+ get_type_hints,
+ overload,
+ runtime_checkable,
+)
+
+import msgspec
+from pydantic import BaseModel
+from typing_extensions import Self, TypedDict, TypeVar
+
+from ._clients import SupportsGetEmbeddings
+from ._feature_stage import ExperimentalFeature, experimental
+from ._telemetry import FeatureIndex, mark_feature_used
+from ._tools import FunctionTool
+from ._types import Content, EmbeddingGenerationOptions
+from .exceptions import IntegrationException, IntegrationInvalidResponseException
+
+ModelT = TypeVar("ModelT", default=Any)
+KeyT = TypeVar("KeyT", default=Any)
+FilterT = TypeVar("FilterT")
+ResultT = TypeVar("ResultT")
+DecoratedModelT = TypeVar("DecoratedModelT")
+
+SearchType: TypeAlias = Literal["vector", "keyword_hybrid"]
+FieldTypes: TypeAlias = Literal["key", "vector", "data"]
+IndexKind: TypeAlias = Literal["hnsw", "flat", "ivf_flat", "disk_ann", "quantized_flat", "dynamic", "default"]
+DistanceFunction: TypeAlias = Literal[
+ "cosine_similarity",
+ "cosine_distance",
+ "dot_prod",
+ "euclidean_distance",
+ "euclidean_squared_distance",
+ "manhattan",
+ "hamming",
+ "DEFAULT",
+]
+Vector: TypeAlias = Sequence[float | int]
+RecordFilter: TypeAlias = Callable[[Any], bool] | str
+RecordFilters: TypeAlias = RecordFilter | Sequence[RecordFilter]
+EmbeddingClient: TypeAlias = SupportsGetEmbeddings[Any, Any, Any]
+VectorModelEncoder: TypeAlias = Callable[[Any], Mapping[str, Any]]
+VectorModelDecoder: TypeAlias = Callable[[Mapping[str, Any]], Any]
+
+_DEFAULT_SEARCH_TOOL_NAME: Final[str] = "search"
+_DEFAULT_SEARCH_TOOL_DESCRIPTION: Final[str] = (
+ "Perform a vector search for data in a vector store using the provided query."
+)
+_INDEX_KINDS: Final[tuple[str, ...]] = (
+ "hnsw",
+ "flat",
+ "ivf_flat",
+ "disk_ann",
+ "quantized_flat",
+ "dynamic",
+ "default",
+)
+_DISTANCE_FUNCTIONS: Final[tuple[str, ...]] = (
+ "cosine_similarity",
+ "cosine_distance",
+ "dot_prod",
+ "euclidean_distance",
+ "euclidean_squared_distance",
+ "manhattan",
+ "hamming",
+ "DEFAULT",
+)
+
+
+DISTANCE_FUNCTION_DIRECTION_HELPER: Final[Mapping[DistanceFunction, Callable[[float | int, float | int], bool]]] = {
+ "cosine_similarity": operator.ge,
+ "cosine_distance": operator.le,
+ "dot_prod": operator.ge,
+ "euclidean_distance": operator.le,
+ "euclidean_squared_distance": operator.le,
+ "manhattan": operator.le,
+ "hamming": operator.le,
+}
+
+
+def _msgspec_enc_hook(value: Any) -> Any:
+ if isinstance(value, BaseModel):
+ return value.model_dump()
+ to_list = getattr(value, "tolist", None)
+ if callable(to_list):
+ return to_list()
+ if hasattr(value, "__dict__"):
+ return cast(dict[str, Any], vars(value))
+ raise NotImplementedError(f"Objects of type {type(value).__name__!r} are not supported.")
+
+
+def _normalize_vector(value: Any) -> Vector:
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
+ return cast(Vector, value)
+ to_list = getattr(value, "tolist", None)
+ if callable(to_list):
+ converted = to_list()
+ if isinstance(converted, Sequence) and not isinstance(converted, (str, bytes, bytearray)):
+ return cast(Vector, converted)
+ raise TypeError("The embedding client returned an unsupported vector type.")
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+@dataclass(frozen=True, slots=True, init=False)
+class VectorStoreField:
+ """Describe one field in a vector store model."""
+
+ field_type: FieldTypes
+ name: str
+ type_: str | None
+ storage_name: str | None
+ is_indexed: bool | None
+ is_full_text_indexed: bool | None
+ dimensions: int | None
+ index_kind: IndexKind | None
+ distance_function: DistanceFunction | None
+ embedding_generator: EmbeddingClient | None
+
+ @overload
+ def __init__(
+ self,
+ field_type: Literal["key"],
+ *,
+ name: str | None = None,
+ type_: str | None = None,
+ storage_name: str | None = None,
+ ) -> None:
+ """Initialize a key field.
+
+ Args:
+ field_type: The key field type.
+ name: The model field name. The decorator supplies this when omitted.
+ type_: The scalar type name used by the backing store.
+ storage_name: The field name used by the backing store.
+ """
+ ...
+
+ @overload
+ def __init__(
+ self,
+ field_type: Literal["data"] = "data",
+ *,
+ name: str | None = None,
+ type_: str | None = None,
+ storage_name: str | None = None,
+ is_indexed: bool | None = None,
+ is_full_text_indexed: bool | None = None,
+ ) -> None:
+ """Initialize a data field with optional indexing.
+
+ Args:
+ field_type: The data field type.
+ name: The model field name. The decorator supplies this when omitted.
+ type_: The scalar type name used by the backing store.
+ storage_name: The field name used by the backing store.
+ is_indexed: Whether the field should be indexed.
+ is_full_text_indexed: Whether the field should have a full-text index.
+ """
+ ...
+
+ @overload
+ def __init__(
+ self,
+ field_type: Literal["vector"],
+ *,
+ name: str | None = None,
+ type_: str | None = None,
+ storage_name: str | None = None,
+ dimensions: int,
+ index_kind: IndexKind | None = None,
+ distance_function: DistanceFunction | None = None,
+ embedding_generator: EmbeddingClient | None = None,
+ ) -> None:
+ """Initialize a vector field with required dimensions.
+
+ Args:
+ field_type: The vector field type.
+ name: The model field name. The decorator supplies this when omitted.
+ type_: The vector element type name used by the backing store.
+ storage_name: The field name used by the backing store.
+ dimensions: The number of vector dimensions.
+ index_kind: The vector index kind.
+ distance_function: The vector distance function.
+ embedding_generator: An optional client used to generate this field's embeddings.
+
+ Raises:
+ ValueError: If dimensions or vector options are invalid.
+ """
+ ...
+
+ def __init__(
+ self,
+ field_type: FieldTypes = "data",
+ *,
+ name: str | None = None,
+ type_: str | None = None,
+ storage_name: str | None = None,
+ is_indexed: bool | None = None,
+ is_full_text_indexed: bool | None = None,
+ dimensions: int | None = None,
+ index_kind: IndexKind | None = None,
+ distance_function: DistanceFunction | None = None,
+ embedding_generator: EmbeddingClient | None = None,
+ ) -> None:
+ """Initialize a vector store field.
+
+ Args:
+ field_type: The field's role in the vector store model.
+ name: The model field name. The decorator supplies this when omitted.
+ type_: The scalar type name used by the backing store.
+ storage_name: The field name used by the backing store.
+ is_indexed: Whether a data field should be indexed.
+ is_full_text_indexed: Whether a data field should have a full-text index.
+ dimensions: The number of vector dimensions. Required for vector fields.
+ index_kind: The vector index kind.
+ distance_function: The vector distance function.
+ embedding_generator: An optional client used to generate this field's embeddings.
+
+ Raises:
+ ValueError: If field options are invalid.
+ """
+ if field_type not in ("key", "vector", "data"):
+ raise ValueError(f"Unknown vector store field type '{field_type}'.")
+ resolved_dimensions: int | None = None
+ resolved_index_kind: IndexKind | None = None
+ resolved_distance_function: DistanceFunction | None = None
+ resolved_embedding_generator: EmbeddingClient | None = None
+ if field_type == "vector":
+ if dimensions is None or dimensions <= 0:
+ raise ValueError("Vector fields must specify a positive number of dimensions.")
+ if index_kind is not None and index_kind not in _INDEX_KINDS:
+ raise ValueError(f"Unknown vector index kind '{index_kind}'.")
+ if distance_function is not None and distance_function not in _DISTANCE_FUNCTIONS:
+ raise ValueError(f"Unknown vector distance function '{distance_function}'.")
+ resolved_dimensions = dimensions
+ resolved_index_kind = index_kind or "default"
+ resolved_distance_function = distance_function or "DEFAULT"
+ resolved_embedding_generator = embedding_generator
+ elif any(value is not None for value in (dimensions, index_kind, distance_function, embedding_generator)):
+ raise ValueError("Vector-only options can only be set on vector fields.")
+
+ object.__setattr__(self, "field_type", field_type)
+ object.__setattr__(self, "name", name or "")
+ object.__setattr__(self, "type_", type_)
+ object.__setattr__(self, "storage_name", storage_name)
+ object.__setattr__(self, "is_indexed", is_indexed)
+ object.__setattr__(self, "is_full_text_indexed", is_full_text_indexed)
+ object.__setattr__(self, "dimensions", resolved_dimensions)
+ object.__setattr__(self, "index_kind", resolved_index_kind)
+ object.__setattr__(self, "distance_function", resolved_distance_function)
+ object.__setattr__(self, "embedding_generator", resolved_embedding_generator)
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+@dataclass(frozen=True, slots=True, init=False)
+class VectorStoreCollectionDefinition:
+ """Describe the records stored in a vector collection.
+
+ Most users should not create this class directly. Applying
+ :func:`vectorstoremodel` to a typed model derives and registers its
+ collection definition automatically.
+
+ Create a definition explicitly for schema-less records such as dictionaries,
+ or when adapting an externally owned model through
+ :func:`register_vectorstoremodel`.
+ """
+
+ fields: tuple[VectorStoreField, ...]
+ collection_name: str | None
+ key_name: str
+
+ def __init__(
+ self,
+ fields: Sequence[VectorStoreField],
+ *,
+ collection_name: str | None = None,
+ ) -> None:
+ """Initialize a vector store collection definition.
+
+ Args:
+ fields: The key, data, and vector fields in each record.
+ collection_name: The collection name associated with the model.
+
+ Raises:
+ ValueError: If field names or key fields are invalid.
+ """
+ object.__setattr__(self, "fields", tuple(fields))
+ object.__setattr__(self, "collection_name", collection_name)
+ object.__setattr__(self, "key_name", self._validate())
+
+ def _validate(self) -> str:
+ if not self.fields:
+ raise ValueError("A vector store definition must contain at least one field.")
+ if any(not field.name for field in self.fields):
+ raise ValueError("Vector store field names must not be empty.")
+
+ names = [field.name for field in self.fields]
+ if len(names) != len(set(names)):
+ raise ValueError("Vector store field names must be unique.")
+ storage_names = [field.storage_name or field.name for field in self.fields]
+ if len(storage_names) != len(set(storage_names)):
+ raise ValueError("Vector store field storage names must be unique.")
+
+ key_fields = [field for field in self.fields if field.field_type == "key"]
+ if len(key_fields) != 1:
+ raise ValueError("A vector store definition must contain exactly one key field.")
+ return key_fields[0].name
+
+ @property
+ def names(self) -> list[str]:
+ """Get the model field names."""
+ return [field.name for field in self.fields]
+
+ @property
+ def storage_names(self) -> list[str]:
+ """Get the backing store field names."""
+ return [field.storage_name or field.name for field in self.fields]
+
+ @property
+ def key_field(self) -> VectorStoreField:
+ """Get the key field."""
+ return next(field for field in self.fields if field.field_type == "key")
+
+ @property
+ def key_field_storage_name(self) -> str:
+ """Get the key field's backing store name."""
+ return self.key_field.storage_name or self.key_field.name
+
+ @property
+ def vector_fields(self) -> list[VectorStoreField]:
+ """Get the vector fields."""
+ return [field for field in self.fields if field.field_type == "vector"]
+
+ @property
+ def data_fields(self) -> list[VectorStoreField]:
+ """Get the data fields."""
+ return [field for field in self.fields if field.field_type == "data"]
+
+ @property
+ def vector_field_names(self) -> list[str]:
+ """Get the vector field names."""
+ return [field.name for field in self.vector_fields]
+
+ @property
+ def data_field_names(self) -> list[str]:
+ """Get the data field names."""
+ return [field.name for field in self.data_fields]
+
+ def try_get_vector_field(self, field_name: str | None = None) -> VectorStoreField | None:
+ """Get a vector field by model or storage name, defaulting to the first vector field."""
+ if field_name is None:
+ return self.vector_fields[0] if self.vector_fields else None
+ return next(
+ (field for field in self.vector_fields if field.name == field_name or field.storage_name == field_name),
+ None,
+ )
+
+ def get_names(self, *, include_vector_fields: bool = True, include_key_field: bool = True) -> list[str]:
+ """Get selected model field names."""
+ return [
+ field.name
+ for field in self.fields
+ if field.field_type == "data"
+ or (field.field_type == "vector" and include_vector_fields)
+ or (field.field_type == "key" and include_key_field)
+ ]
+
+ def get_storage_names(self, *, include_vector_fields: bool = True, include_key_field: bool = True) -> list[str]:
+ """Get selected backing store field names."""
+ return [
+ field.storage_name or field.name
+ for field in self.fields
+ if field.field_type == "data"
+ or (field.field_type == "vector" and include_vector_fields)
+ or (field.field_type == "key" and include_key_field)
+ ]
+
+
+@dataclass(frozen=True, slots=True)
+class _VectorModelRegistration:
+ record_type: type[Any]
+ definition: VectorStoreCollectionDefinition
+ encoder: VectorModelEncoder
+ decoder: VectorModelDecoder
+
+
+_VECTOR_MODEL_REGISTRY: dict[type[Any], _VectorModelRegistration] = {}
+
+
+def _default_vector_model_encoder(record_type: type[Any]) -> VectorModelEncoder:
+ def encode(value: Any) -> Mapping[str, Any]:
+ if not isinstance(value, record_type):
+ raise TypeError(f"Expected {record_type.__name__}, got {type(value).__name__}.")
+ converted = msgspec.to_builtins(value, str_keys=True, enc_hook=_msgspec_enc_hook)
+ if not isinstance(converted, Mapping):
+ raise TypeError(f"Vector model {record_type.__name__!r} must serialize to a mapping.")
+ return cast(Mapping[str, Any], converted)
+
+ return encode
+
+
+def _default_vector_model_decoder(record_type: type[Any]) -> VectorModelDecoder:
+ if issubclass(record_type, BaseModel):
+
+ def decode_pydantic(value: Mapping[str, Any]) -> Any:
+ validation_value = {
+ field.validation_alias
+ if isinstance(field.validation_alias, str)
+ else field.alias
+ if isinstance(field.alias, str)
+ else name: value[name]
+ for name, field in record_type.model_fields.items()
+ if name in value
+ }
+ return record_type.model_validate(validation_value)
+
+ return decode_pydantic
+ if is_dataclass(record_type) or issubclass(record_type, msgspec.Struct):
+ return lambda value: msgspec.convert(value, record_type)
+ return lambda value: record_type(**value)
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+def register_vectorstoremodel(
+ record_type: type[ModelT],
+ *,
+ definition: VectorStoreCollectionDefinition,
+ encoder: Callable[[ModelT], Mapping[str, Any]] | None = None,
+ decoder: Callable[[Mapping[str, Any]], ModelT] | None = None,
+) -> None:
+ """Register one vector store definition and codec pair for a model type.
+
+ Args:
+ record_type: The model type to register.
+ definition: The vector store collection definition for the model.
+ encoder: Optional callback that converts a model instance to a mapping.
+ decoder: Optional callback that reconstructs a model instance from a mapping.
+ This can restore array-like fields such as NumPy arrays without requiring
+ Agent Framework to depend on NumPy.
+
+ Raises:
+ ValueError: If the model type is already registered differently.
+ """
+ existing = _VECTOR_MODEL_REGISTRY.get(record_type)
+ if existing is not None:
+ if existing.definition is not definition:
+ raise ValueError(f"Vector model {record_type.__name__!r} is already registered with another definition.")
+ if encoder is not None and existing.encoder is not encoder:
+ raise ValueError(f"Vector model {record_type.__name__!r} is already registered with another encoder.")
+ if decoder is not None and existing.decoder is not decoder:
+ raise ValueError(f"Vector model {record_type.__name__!r} is already registered with another decoder.")
+ return
+ if decoder is None:
+ required_vector_fields = [
+ field.name for field in definition.vector_fields if not _has_default(record_type, field.name)
+ ]
+ if required_vector_fields:
+ raise ValueError(
+ "Vector fields omitted by include_vectors=False must declare defaults when using the default decoder. "
+ f"Add defaults or supply a custom decoder for: {', '.join(required_vector_fields)}."
+ )
+ resolved_encoder = (
+ cast(VectorModelEncoder, encoder) if encoder is not None else _default_vector_model_encoder(record_type)
+ )
+ resolved_decoder = (
+ cast(VectorModelDecoder, decoder) if decoder is not None else _default_vector_model_decoder(record_type)
+ )
+ registration = _VectorModelRegistration(
+ record_type=record_type,
+ definition=definition,
+ encoder=resolved_encoder,
+ decoder=resolved_decoder,
+ )
+ _VECTOR_MODEL_REGISTRY[record_type] = registration
+
+
+def _has_default(record_type: type[Any], field_name: str) -> bool:
+ if issubclass(record_type, BaseModel) and field_name in record_type.model_fields:
+ return not record_type.model_fields[field_name].is_required()
+ try:
+ parameter = signature(record_type).parameters.get(field_name)
+ except (TypeError, ValueError):
+ parameter = None
+ if parameter is not None:
+ return parameter.default is not Parameter.empty
+ return hasattr(record_type, field_name)
+
+
+def _unwrap_annotation(annotation: Any) -> Any:
+ if get_origin(annotation) is Annotated:
+ return get_args(annotation)[0]
+ return annotation
+
+
+def _without_none(annotation: Any) -> tuple[Any, ...]:
+ args = get_args(annotation)
+ if get_origin(annotation) in (UnionType, Union):
+ return tuple(arg for arg in args if arg is not type(None))
+ return (annotation,)
+
+
+def _infer_type_name(annotation: Any, *, vector: bool) -> str | None:
+ candidates = _without_none(_unwrap_annotation(annotation))
+ if vector:
+ for candidate in candidates:
+ origin = get_origin(candidate)
+ args = get_args(candidate)
+ if origin is not None and args:
+ candidate = next((arg for arg in args if arg is not Ellipsis), candidate)
+ return getattr(candidate, "__name__", str(candidate))
+ candidate = candidates[0] if candidates else annotation
+ origin = get_origin(candidate)
+ return getattr(origin or candidate, "__name__", None)
+
+
+def _parse_model_definition(
+ record_type: type[Any],
+ *,
+ collection_name: str | None,
+) -> VectorStoreCollectionDefinition:
+ try:
+ annotations = get_type_hints(record_type, include_extras=True)
+ except (NameError, TypeError) as exc:
+ raise ValueError(f"Unable to resolve annotations for {record_type.__name__}: {exc}") from exc
+ uses_init_annotations = not any(
+ any(isinstance(metadata, VectorStoreField) for metadata in get_args(annotation)[1:])
+ for annotation in annotations.values()
+ if get_origin(annotation) is Annotated
+ )
+ init_parameters: Mapping[str, Parameter] = {}
+ if uses_init_annotations:
+ try:
+ annotations = {
+ name: annotation
+ for name, annotation in get_type_hints(record_type.__init__, include_extras=True).items()
+ if name not in {"self", "return"}
+ }
+ init_parameters = signature(record_type.__init__).parameters
+ except (NameError, TypeError, ValueError) as exc:
+ raise ValueError(f"Unable to resolve constructor annotations for {record_type.__name__}: {exc}") from exc
+ if not annotations:
+ raise ValueError("A vector store model must declare at least one annotated field or constructor parameter.")
+
+ fields: list[VectorStoreField] = []
+ for name, annotation in annotations.items():
+ metadata = get_args(annotation)[1:] if get_origin(annotation) is Annotated else ()
+ field = next((item for item in metadata if isinstance(item, VectorStoreField)), None)
+ if field is None:
+ has_default = (
+ init_parameters[name].default is not Parameter.empty
+ if uses_init_annotations
+ else _has_default(record_type, name)
+ )
+ if not has_default:
+ raise ValueError(f"Field '{name}' must use VectorStoreField metadata or declare a default value.")
+ continue
+
+ parsed_field = replace(
+ field,
+ name=name,
+ type_=field.type_ or _infer_type_name(annotation, vector=field.field_type == "vector"),
+ )
+ fields.append(parsed_field)
+ return VectorStoreCollectionDefinition(fields, collection_name=collection_name)
+
+
+class _VectorStoreModelDecorator(Protocol):
+ def __call__(self, record_type: type[DecoratedModelT]) -> type[DecoratedModelT]:
+ """Decorate a model while preserving its concrete type."""
+ ...
+
+
+@overload
+def vectorstoremodel(cls: type[ModelT]) -> type[ModelT]:
+ """Decorate a vector store model without arguments.
+
+ Args:
+ cls: The class to decorate.
+
+ Returns:
+ The original class with vector store model metadata attached.
+
+ Raises:
+ ValueError: If the model definition is invalid.
+ """
+ ...
+
+
+@overload
+def vectorstoremodel(
+ cls: None = None,
+ *,
+ collection_name: str | None = None,
+ encoder: Callable[[Any], Mapping[str, Any]] | None = None,
+ decoder: Callable[[Mapping[str, Any]], Any] | None = None,
+) -> _VectorStoreModelDecorator:
+ """Create a vector store model decorator with a collection name.
+
+ Args:
+ cls: The empty decorator target used when calling the decorator with arguments.
+ collection_name: The collection name associated with the model.
+ encoder: Optional callback that converts a model instance to a mapping.
+ decoder: Optional callback that reconstructs a model instance from a mapping.
+ This can restore array-like fields such as NumPy arrays without requiring
+ Agent Framework to depend on NumPy.
+
+ Returns:
+ A decorator that attaches vector store model metadata.
+
+ Raises:
+ ValueError: When the returned decorator receives an invalid model definition.
+ """
+ ...
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+def vectorstoremodel(
+ cls: type[Any] | None = None,
+ *,
+ collection_name: str | None = None,
+ encoder: Callable[[Any], Mapping[str, Any]] | None = None,
+ decoder: Callable[[Mapping[str, Any]], Any] | None = None,
+) -> type[Any] | _VectorStoreModelDecorator:
+ """Mark a class as a vector store model.
+
+ Class fields or constructor parameters use ``Annotated`` metadata to describe their
+ vector store role. Dataclasses, Pydantic models, and plain classes are supported.
+ Dictionaries use an explicit :class:`VectorStoreCollectionDefinition` instead.
+
+ Args:
+ cls: The class to decorate.
+ collection_name: The collection name associated with the model.
+ encoder: Optional callback that converts a model instance to a mapping.
+ decoder: Optional callback that reconstructs a model instance from a mapping.
+
+ Returns:
+ The original class with vector store model metadata attached.
+
+ Raises:
+ ValueError: If the model definition is invalid.
+ """
+
+ def wrap(record_type: type[DecoratedModelT]) -> type[DecoratedModelT]:
+ definition = _parse_model_definition(record_type, collection_name=collection_name)
+ register_vectorstoremodel(
+ record_type,
+ definition=definition,
+ encoder=encoder,
+ decoder=decoder,
+ )
+ decorated_type = cast(Any, record_type)
+ decorated_type.__vectorstoremodel__ = True
+ decorated_type.__vectorstoremodel_definition__ = definition
+ return record_type
+
+ return wrap if cls is None else wrap(cls)
+
+
+def _validate_paging(*, top: int, skip: int) -> None:
+ if not isinstance(top, int) or isinstance(top, bool):
+ raise TypeError("top must be an integer.")
+ if not isinstance(skip, int) or isinstance(skip, bool):
+ raise TypeError("skip must be an integer.")
+ if top <= 0:
+ raise ValueError("top must be greater than zero.")
+ if skip < 0:
+ raise ValueError("skip must not be negative.")
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class SearchResponse(TypedDict, Generic[ModelT]):
+ """One vector search result."""
+
+ record: ModelT
+ score: float | None
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class SearchResults(Generic[ResultT]):
+ """A lazily consumed set of vector search results.
+
+ Connector-native counts may be placed in ``metadata`` together with enough
+ provider-specific context to explain their scope.
+ """
+
+ def __init__(
+ self,
+ results: AsyncIterable[ResultT] | Sequence[ResultT],
+ *,
+ metadata: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Initialize search results."""
+ self.results = _as_async_iterable(results)
+ self.metadata = metadata
+
+ def __aiter__(self) -> AsyncIterator[ResultT]:
+ """Iterate over results regardless of whether their source was synchronous or asynchronous."""
+ return self.results.__aiter__()
+
+
+class _VectorStoreRecordHandler(Generic[KeyT, ModelT]):
+ """Serialize and deserialize application records for a vector store."""
+
+ supported_key_types: ClassVar[set[str] | None] = None
+ supported_vector_types: ClassVar[set[str] | None] = None
+
+ def __init__(
+ self,
+ record_type: type[ModelT],
+ *,
+ definition: VectorStoreCollectionDefinition | None = None,
+ embedding_generator: EmbeddingClient | None = None,
+ ) -> None:
+ """Initialize a vector store record handler.
+
+ Args:
+ record_type: The application record type.
+ definition: The collection definition. Decorated models supply this automatically.
+ embedding_generator: The default client used for local vector generation.
+
+ Raises:
+ ValueError: If no model registration or explicit dictionary definition is available.
+ """
+ registration = _VECTOR_MODEL_REGISTRY.get(record_type)
+ if record_type is dict:
+ if definition is None:
+ raise ValueError("Dictionary record types require an explicit VectorStoreCollectionDefinition.")
+ resolved_definition = definition
+ else:
+ if registration is None:
+ raise ValueError(
+ f"Record type {record_type.__name__!r} must be registered with "
+ "@vectorstoremodel or register_vectorstoremodel()."
+ )
+ if definition is not None and definition is not registration.definition:
+ raise ValueError(f"Record type {record_type.__name__!r} is registered with another definition.")
+ resolved_definition = registration.definition
+ self.record_type = record_type
+ self.definition = resolved_definition
+ self._model_registration = registration
+ self.embedding_generator = embedding_generator
+ self._validate_data_model()
+
+ def _validate_data_model(self) -> None:
+ key_type = self.definition.key_field.type_
+ if self.supported_key_types and key_type and key_type not in self.supported_key_types:
+ raise ValueError(f"Key field type must be one of {self.supported_key_types}; got '{key_type}'.")
+ if not self.supported_vector_types:
+ return
+ for field in self.definition.vector_fields:
+ if field.type_ and field.type_ not in self.supported_vector_types:
+ raise ValueError(
+ f"Vector field '{field.name}' type must be one of {self.supported_vector_types}; "
+ f"got '{field.type_}'."
+ )
+
+ def _serialize_dicts_to_store_models(
+ self,
+ records: Sequence[dict[str, Any]],
+ *,
+ context: Mapping[str, Any] | None = None,
+ ) -> Sequence[Any]:
+ """Convert dictionaries to store-specific records."""
+ return records
+
+ def _deserialize_store_models_to_dicts(
+ self,
+ records: Sequence[Any],
+ *,
+ context: Mapping[str, Any] | None = None,
+ ) -> Sequence[dict[str, Any]]:
+ """Convert store-specific records to dictionaries."""
+ dict_records: list[dict[str, Any]] = []
+ for record in records:
+ if not isinstance(record, Mapping):
+ raise TypeError("Store records must be mappings unless the collection overrides deserialization.")
+ dict_records.append(dict(cast(Mapping[str, Any], record)))
+ return dict_records
+
+ async def serialize(
+ self,
+ records: ModelT | Sequence[ModelT],
+ *,
+ generate_vectors: bool = True,
+ context: Mapping[str, Any] | None = None,
+ ) -> Any:
+ """Serialize one or more application records for the backing store.
+
+ Args:
+ records: One application record or a sequence of records.
+ generate_vectors: Whether to generate vector values, overwriting any supplied values. When ``False``,
+ supplied values are preserved.
+ context: Connector-specific serialization context.
+
+ Raises:
+ TypeError: If a record cannot be converted to a mapping.
+ ValueError: If required record data is missing, has an invalid shape, or a vector field has no generator.
+ IntegrationInvalidResponseException: If embedding generation returns an unexpected result count.
+ """
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ is_batch = _is_non_string_sequence(records)
+ input_records = list(cast(Sequence[ModelT], records)) if is_batch else [cast(ModelT, records)]
+ dict_records = [self._serialize_record_to_dict(record) for record in input_records]
+
+ if generate_vectors:
+ await self._add_vectors_to_records(dict_records)
+ store_models = list(self._serialize_dicts_to_store_models(dict_records, context=context))
+
+ if len(store_models) != len(dict_records):
+ raise IntegrationInvalidResponseException(
+ f"Expected {len(dict_records)} serialized records, but the connector returned {len(store_models)}."
+ )
+ if is_batch:
+ return store_models
+ if len(store_models) != 1:
+ raise ValueError(f"Expected one serialized record, but the serializer returned {len(store_models)}.")
+ return store_models[0]
+
+ def _serialize_record_to_dict(self, record: ModelT) -> dict[str, Any]:
+ if self.record_type is dict:
+ source = self._to_builtin_mapping(record)
+ else:
+ if self._model_registration is None:
+ raise RuntimeError(f"Vector model {self.record_type.__name__!r} is not registered.")
+ source = self._to_builtin_mapping(self._model_registration.encoder(record))
+ return self._serialize_mapping_to_store(source)
+
+ @staticmethod
+ def _to_builtin_mapping(record: Any) -> Mapping[str, Any]:
+ converted = msgspec.to_builtins(record, str_keys=True, enc_hook=_msgspec_enc_hook)
+ if not isinstance(converted, Mapping):
+ raise TypeError("Vector records must serialize to mappings.")
+ return cast(Mapping[str, Any], converted)
+
+ def _serialize_mapping_to_store(self, source: Mapping[str, Any]) -> dict[str, Any]:
+ serialized: dict[str, Any] = {}
+ for field in self.definition.fields:
+ if field.name in source:
+ value = source[field.name]
+ elif field.storage_name is not None and field.storage_name in source:
+ value = source[field.storage_name]
+ else:
+ raise ValueError(f"Record is missing vector store field '{field.name}'.")
+ serialized[field.storage_name or field.name] = value
+ return serialized
+
+ async def _add_vectors_to_records(self, records: Sequence[dict[str, Any]]) -> None:
+ field_generators: list[tuple[VectorStoreField, EmbeddingClient]] = []
+ for field in self.definition.vector_fields:
+ embedding_generator = field.embedding_generator or self.embedding_generator
+ if embedding_generator is None:
+ raise ValueError(
+ f"Vector field '{field.name}' has no embedding generator. "
+ "Set generate_vectors=False to preserve supplied vector values."
+ )
+ field_generators.append((field, embedding_generator))
+
+ for field, embedding_generator in field_generators:
+ storage_name = field.storage_name or field.name
+ values = [record.get(storage_name) for record in records]
+ if any(value is None for value in values):
+ raise ValueError(
+ f"Vector field '{field.name}' cannot be embedded because at least one value is missing."
+ )
+ options: EmbeddingGenerationOptions = {}
+ if field.dimensions is not None:
+ options["dimensions"] = field.dimensions
+ embeddings = await embedding_generator.get_embeddings(values, options=options)
+ if len(embeddings) != len(records):
+ raise IntegrationInvalidResponseException(
+ f"Embedding client returned {len(embeddings)} vectors for {len(records)} records."
+ )
+ for record, embedding in zip(records, embeddings, strict=True):
+ record[storage_name] = _normalize_vector(embedding.vector)
+
+ def deserialize(
+ self,
+ records: Any | Sequence[Any],
+ *,
+ include_vectors: bool = True,
+ context: Mapping[str, Any] | None = None,
+ ) -> ModelT | Sequence[ModelT] | None:
+ """Deserialize one or more backing store records.
+
+ Raises:
+ TypeError: If a store record has an unsupported type.
+ ValueError: If records cannot be reconstructed into the requested model shape.
+ """
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ if records is None:
+ return None
+ is_batch = _is_non_string_sequence(records)
+ input_records = list(records) if is_batch else [records]
+ dict_records = self._deserialize_store_models_to_dicts(input_records, context=context)
+ if not dict_records:
+ return [] if is_batch else None
+ deserialized = [
+ self._deserialize_dict_to_record(record, include_vectors=include_vectors) for record in dict_records
+ ]
+ return deserialized if is_batch else deserialized[0]
+
+ def _deserialize_dict_to_record(
+ self,
+ record: Mapping[str, Any],
+ *,
+ include_vectors: bool,
+ ) -> ModelT:
+ logical_record = self._deserialize_dict_to_mapping(record, include_vectors=include_vectors)
+ if self.record_type is dict:
+ return cast(ModelT, logical_record)
+ if self._model_registration is None:
+ raise RuntimeError(f"Vector model {self.record_type.__name__!r} is not registered.")
+ return cast(ModelT, self._model_registration.decoder(logical_record))
+
+ def _deserialize_dict_to_mapping(
+ self,
+ record: Mapping[str, Any],
+ *,
+ include_vectors: bool,
+ ) -> dict[str, Any]:
+ logical_record: dict[str, Any] = {}
+ for field in self.definition.fields:
+ if not include_vectors and field.field_type == "vector":
+ continue
+ storage_name = field.storage_name or field.name
+ if storage_name not in record:
+ raise IntegrationInvalidResponseException(
+ f"Vector store response is missing required field '{storage_name}'."
+ )
+ logical_record[field.name] = record[storage_name]
+ return logical_record
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class BaseVectorCollection(_VectorStoreRecordHandler[KeyT, ModelT], ABC):
+ """Base class for vector store collection CRUD operations."""
+
+ def __init__(
+ self,
+ record_type: type[ModelT],
+ *,
+ definition: VectorStoreCollectionDefinition | None = None,
+ collection_name: str | None = None,
+ embedding_generator: EmbeddingClient | None = None,
+ managed_client: bool = True,
+ ) -> None:
+ """Initialize a vector store collection."""
+ super().__init__(
+ record_type,
+ definition=definition,
+ embedding_generator=embedding_generator,
+ )
+ self.collection_name = collection_name or self.definition.collection_name or ""
+ if not self.collection_name:
+ raise ValueError("A collection name is required when the model definition does not provide one.")
+ self.managed_client = managed_client
+
+ async def __aenter__(self) -> Self:
+ """Enter the collection context manager."""
+ return self
+
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
+ """Exit the collection context manager."""
+
+ @abstractmethod
+ async def ensure_collection_exists(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Create the collection when it does not exist."""
+ ...
+
+ @abstractmethod
+ async def collection_exists(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> bool:
+ """Check whether the collection exists."""
+ ...
+
+ @abstractmethod
+ async def ensure_collection_deleted(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Delete the collection when it exists."""
+ ...
+
+ @abstractmethod
+ async def _inner_upsert(
+ self,
+ records: Sequence[Any],
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[KeyT]:
+ """Upsert serialized records and return their keys."""
+ ...
+
+ @abstractmethod
+ async def _inner_get(
+ self,
+ *,
+ keys: Sequence[KeyT] | None = None,
+ top: int = 10,
+ skip: int = 0,
+ order_by: Mapping[str, bool] | None = None,
+ include_vectors: bool = False,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[Any] | None:
+ """Retrieve store-specific records."""
+ ...
+
+ @abstractmethod
+ async def _inner_delete(
+ self,
+ keys: Sequence[KeyT],
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Delete records by key."""
+ ...
+
+ async def upsert(
+ self,
+ records: Sequence[ModelT],
+ *,
+ generate_vectors: bool = True,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[KeyT]:
+ """Upsert a batch of records.
+
+ Args:
+ records: A sequence of models.
+ generate_vectors: Whether to generate vector values, overwriting any supplied values. When ``False``,
+ supplied values are preserved.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ The keys of all upserted records.
+
+ Raises:
+ TypeError: If record serialization encounters an unsupported type.
+ ValueError: If record data or returned keys have an invalid shape, or a vector field has no generator.
+ IntegrationException: If the backing store operation fails.
+ IntegrationInvalidResponseException: If the backing store returns an unexpected key count.
+ """
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ if not _is_non_string_sequence(records):
+ raise TypeError("records must be a sequence.")
+ try:
+ serialized = await self.serialize(records, generate_vectors=generate_vectors)
+ store_records = list(serialized) if _is_non_string_sequence(serialized) else [serialized]
+ keys = list(await self._inner_upsert(store_records, operation_options=operation_options))
+ except (TypeError, ValueError):
+ raise
+ except IntegrationException:
+ raise
+ except Exception as exc:
+ raise IntegrationException(
+ f"Error upserting records into collection '{self.collection_name}': {exc}"
+ ) from exc
+ if len(keys) != len(store_records):
+ raise IntegrationInvalidResponseException(
+ f"Expected {len(store_records)} upserted keys, but the store returned {len(keys)}."
+ )
+ return keys
+
+ async def get(
+ self,
+ keys: Sequence[KeyT] | None = None,
+ *,
+ top: int = 10,
+ skip: int = 0,
+ order_by: Mapping[str, bool] | None = None,
+ include_vectors: bool = False,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[ModelT]:
+ """Get records by keys or list a page of records.
+
+ Args:
+ keys: A sequence of keys, or ``None`` to list a page of records.
+ top: The maximum number of records returned when listing.
+ skip: The number of records skipped when listing.
+ order_by: Field names mapped to ascending (``True``) or descending (``False``) order.
+ include_vectors: Whether returned records include vector fields.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ A sequence of models. Keys that do not exist are omitted.
+
+ Raises:
+ ValueError: If paging arguments are invalid.
+ TypeError: If keys or a returned record has an unsupported type.
+ IntegrationException: If retrieval fails.
+ """
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ _validate_paging(top=top, skip=skip)
+ if keys is not None and not _is_non_string_sequence(keys):
+ raise TypeError("keys must be a sequence.")
+ try:
+ records = await self._inner_get(
+ keys=keys,
+ top=top,
+ skip=skip,
+ order_by=order_by,
+ include_vectors=include_vectors,
+ operation_options=operation_options,
+ )
+ except IntegrationException:
+ raise
+ except Exception as exc:
+ raise IntegrationException(
+ f"Error getting records from collection '{self.collection_name}': {exc}"
+ ) from exc
+ if not records:
+ return []
+ deserialized = self.deserialize(records, include_vectors=include_vectors)
+ return [] if deserialized is None else cast(Sequence[ModelT], deserialized)
+
+ async def delete(
+ self,
+ keys: Sequence[KeyT],
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Delete a batch of records by key.
+
+ Args:
+ keys: The keys to delete.
+ operation_options: Store-specific operation options.
+
+ Raises:
+ TypeError: If keys is not a sequence.
+ IntegrationException: If the backing store operation fails.
+ """
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ if not _is_non_string_sequence(keys):
+ raise TypeError("keys must be a sequence.")
+ try:
+ await self._inner_delete(keys, operation_options=operation_options)
+ except IntegrationException:
+ raise
+ except Exception as exc:
+ raise IntegrationException(
+ f"Error deleting records from collection '{self.collection_name}': {exc}"
+ ) from exc
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class BaseVectorStore(ABC):
+ """Base class for vector stores that create collection clients."""
+
+ def __init__(
+ self,
+ *,
+ embedding_generator: EmbeddingClient | None = None,
+ managed_client: bool = True,
+ ) -> None:
+ """Initialize a vector store."""
+ self.embedding_generator = embedding_generator
+ self.managed_client = managed_client
+
+ async def __aenter__(self) -> Self:
+ """Enter the vector store context manager."""
+ return self
+
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
+ """Exit the vector store context manager."""
+
+ @abstractmethod
+ def get_collection(
+ self,
+ record_type: type[ModelT],
+ *,
+ definition: VectorStoreCollectionDefinition | None = None,
+ collection_name: str | None = None,
+ embedding_generator: EmbeddingClient | None = None,
+ ) -> BaseVectorCollection[Any, ModelT]:
+ """Create a collection client tied to this store."""
+ ...
+
+ @abstractmethod
+ async def list_collection_names(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[str]:
+ """List collection names."""
+ ...
+
+ async def collection_exists(
+ self,
+ collection_name: str,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> bool:
+ """Check whether a collection exists."""
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ return collection_name in await self.list_collection_names(operation_options=operation_options)
+
+ async def ensure_collection_deleted(
+ self,
+ collection_name: str,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Delete a collection when it exists."""
+ if not await self.collection_exists(collection_name, operation_options=operation_options):
+ return
+ await self._inner_ensure_collection_deleted(
+ collection_name,
+ operation_options=operation_options,
+ )
+
+ @abstractmethod
+ async def _inner_ensure_collection_deleted(
+ self,
+ collection_name: str,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Delete a collection by name."""
+ ...
+
+
+class _LambdaVisitor(NodeVisitor, Generic[FilterT]):
+ def __init__(self, lambda_parser: Callable[[expr], FilterT]) -> None:
+ self.lambda_parser = lambda_parser
+ self.output_filters: list[FilterT] = []
+
+ def visit_Lambda(self, node: Lambda) -> None:
+ self.output_filters.append(self.lambda_parser(node.body))
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class BaseVectorSearch(_VectorStoreRecordHandler[KeyT, ModelT], ABC):
+ """Base class for vector and keyword-hybrid search."""
+
+ supported_search_types: ClassVar[set[SearchType]] = {"vector"}
+
+ @abstractmethod
+ async def _inner_search(
+ self,
+ *,
+ search_type: SearchType,
+ filter: Any | list[Any] | None = None,
+ values: Any | None = None,
+ vector: Vector | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[Any]:
+ """Execute a search and return raw connector results."""
+ ...
+
+ @abstractmethod
+ def _get_record_from_result(self, result: Any) -> Any:
+ """Extract a store record from one raw search result."""
+ ...
+
+ @abstractmethod
+ def _get_score_from_result(self, result: Any) -> float | None:
+ """Extract a score from one raw search result."""
+ ...
+
+ @abstractmethod
+ def _lambda_parser(self, node: AST) -> Any:
+ """Translate one lambda expression body into a store filter."""
+ ...
+
+ @overload
+ async def search(
+ self,
+ values: Any,
+ *,
+ search_type: SearchType = "vector",
+ vector: Vector | None = None,
+ filter: RecordFilters | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[SearchResponse[ModelT]]:
+ """Search from a value, optionally with a precomputed vector.
+
+ Args:
+ values: The value to search for or vectorize.
+ search_type: Whether to perform vector or keyword-hybrid search.
+ vector: An optional precomputed query vector.
+ filter: One or more lambda filters.
+ top: The maximum number of results.
+ skip: The number of results to skip.
+ include_vectors: Whether returned records include vector fields.
+ vector_property_name: The vector field used for search.
+ additional_property_name: The data field used for keyword-hybrid search.
+ score_threshold: The minimum similarity or maximum distance accepted.
+ Results without scores remain included.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ Lazily consumed search results.
+
+ Raises:
+ ValueError: If paging or search arguments are invalid.
+ NotImplementedError: If the search type is unsupported.
+ IntegrationException: If vector generation or search fails.
+ """
+ ...
+
+ @overload
+ async def search(
+ self,
+ *,
+ search_type: Literal["vector"] = "vector",
+ vector: Vector,
+ filter: RecordFilters | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[SearchResponse[ModelT]]:
+ """Search from a required precomputed vector.
+
+ Args:
+ search_type: The vector search type.
+ vector: The precomputed query vector.
+ filter: One or more lambda filters.
+ top: The maximum number of results.
+ skip: The number of results to skip.
+ include_vectors: Whether returned records include vector fields.
+ vector_property_name: The vector field used for search.
+ additional_property_name: The data field used for keyword-hybrid search.
+ score_threshold: The minimum similarity or maximum distance accepted.
+ Results without scores remain included.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ Lazily consumed search results.
+
+ Raises:
+ ValueError: If paging or search arguments are invalid.
+ NotImplementedError: If vector search is unsupported.
+ IntegrationException: If search execution fails.
+ """
+ ...
+
+ async def search(
+ self,
+ values: Any | None = None,
+ *,
+ search_type: SearchType = "vector",
+ vector: Vector | None = None,
+ filter: RecordFilters | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[SearchResponse[ModelT]]:
+ """Search the vector store.
+
+ Args:
+ values: The value to search for or vectorize.
+ search_type: Whether to perform vector or keyword-hybrid search.
+ vector: A precomputed query vector.
+ filter: One or more lambda filters.
+ top: The maximum number of results.
+ skip: The number of results to skip.
+ include_vectors: Whether returned records include vector fields.
+ vector_property_name: The vector field used for search.
+ additional_property_name: The data field used for keyword-hybrid search.
+ score_threshold: The minimum similarity or maximum distance accepted.
+ Results without scores remain included.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ Lazily consumed search results.
+
+ Raises:
+ ValueError: If paging or search arguments are invalid.
+ NotImplementedError: If the search type is unsupported.
+ IntegrationException: If the backing store search fails.
+ """
+ mark_feature_used(FeatureIndex.CORE_VECTOR_STORES)
+ if search_type not in ("vector", "keyword_hybrid"):
+ raise ValueError(f"Unknown search type '{search_type}'.")
+ if search_type not in self.supported_search_types:
+ raise NotImplementedError(f"Search type '{search_type}' is not supported by {type(self).__name__}.")
+ if values is None and vector is None:
+ raise ValueError("Search requires values or a precomputed vector.")
+ if search_type == "keyword_hybrid" and values is None:
+ raise ValueError("Keyword-hybrid search requires values.")
+
+ _validate_paging(top=top, skip=skip)
+ try:
+ self._validate_score_threshold(
+ score_threshold=score_threshold,
+ vector_property_name=vector_property_name,
+ )
+ resolved_vector = vector
+ if resolved_vector is None and values is not None:
+ resolved_vector = await self._generate_vector_from_values(
+ values,
+ vector_property_name=vector_property_name,
+ )
+ translated_filter = self._build_filter(filter)
+ raw_results = await self._inner_search(
+ search_type=search_type,
+ filter=translated_filter,
+ values=values,
+ vector=resolved_vector,
+ top=top,
+ skip=skip,
+ include_vectors=include_vectors,
+ vector_property_name=vector_property_name,
+ additional_property_name=additional_property_name,
+ score_threshold=score_threshold,
+ operation_options=operation_options,
+ )
+ return SearchResults(
+ self._get_search_results_from_results(
+ raw_results.results,
+ include_vectors=include_vectors,
+ vector_property_name=vector_property_name,
+ score_threshold=score_threshold,
+ ),
+ metadata=raw_results.metadata,
+ )
+ except (TypeError, ValueError):
+ raise
+ except IntegrationException:
+ raise
+ except Exception as exc:
+ raise IntegrationException(f"Vector search failed: {exc}") from exc
+
+ def _validate_score_threshold(
+ self,
+ *,
+ score_threshold: float | None,
+ vector_property_name: str | None,
+ ) -> None:
+ if score_threshold is None:
+ return
+ vector_field = self.definition.try_get_vector_field(vector_property_name)
+ if vector_field is None:
+ raise ValueError("A score threshold requires a vector field.")
+ if vector_field.distance_function == "DEFAULT":
+ raise ValueError("A score threshold requires an explicit distance function on the vector field.")
+
+ async def _generate_vector_from_values(
+ self,
+ values: Any,
+ *,
+ vector_property_name: str | None,
+ ) -> Vector | None:
+ vector_field = self.definition.try_get_vector_field(vector_property_name)
+ if vector_field is None:
+ if vector_property_name is not None:
+ raise ValueError(f"Vector field '{vector_property_name}' was not found in the collection definition.")
+ return None
+ embedding_generator = vector_field.embedding_generator or self.embedding_generator
+ if embedding_generator is None:
+ return None
+ embedding_options: EmbeddingGenerationOptions = {}
+ if vector_field.dimensions is not None:
+ embedding_options["dimensions"] = vector_field.dimensions
+ embeddings = await embedding_generator.get_embeddings([values], options=embedding_options)
+ if len(embeddings) != 1:
+ raise IntegrationInvalidResponseException(
+ f"Embedding client returned {len(embeddings)} vectors for one search value."
+ )
+ generated_vector = embeddings[0].vector
+ return _normalize_vector(generated_vector)
+
+ def _build_filter(self, search_filter: RecordFilters | None) -> Any | list[Any] | None:
+ """Translate lambda filters with the connector's AST parser."""
+ if not search_filter:
+ return None
+ filters: list[RecordFilter]
+ if _is_non_string_sequence(search_filter) and not callable(search_filter):
+ filters = cast(list[RecordFilter], list(search_filter))
+ else:
+ filters = [cast(RecordFilter, search_filter)]
+ visitor = _LambdaVisitor(self._lambda_parser)
+ try:
+ for filter_item in filters:
+ source = (
+ filter_item
+ if isinstance(filter_item, str)
+ else getsource(cast(Callable[..., Any], filter_item)).strip()
+ )
+ visitor.visit(parse(source))
+ except (OSError, SyntaxError, TypeError) as exc:
+ raise ValueError(f"Unable to parse vector search filter: {exc}") from exc
+ if not visitor.output_filters:
+ raise ValueError("No lambda expression was found in the vector search filter.")
+ return visitor.output_filters[0] if len(visitor.output_filters) == 1 else visitor.output_filters
+
+ def _get_search_results_from_results(
+ self,
+ results: AsyncIterable[Any] | Sequence[Any],
+ *,
+ include_vectors: bool,
+ vector_property_name: str | None,
+ score_threshold: float | None,
+ ) -> AsyncIterable[SearchResponse[ModelT]]:
+ """Convert raw connector results into deserialized search responses."""
+
+ async def generate() -> AsyncIterator[SearchResponse[ModelT]]:
+ try:
+ async for result in _as_async_iterable(results):
+ try:
+ record = self.deserialize(
+ self._get_record_from_result(result),
+ include_vectors=include_vectors,
+ )
+ if record is None or _is_non_string_sequence(record):
+ if record is None:
+ continue
+ raise IntegrationInvalidResponseException(
+ "A search result must deserialize to exactly one record."
+ )
+ score = self._get_score_from_result(result)
+ if not self._meets_score_threshold(
+ score,
+ score_threshold=score_threshold,
+ vector_property_name=vector_property_name,
+ ):
+ continue
+ yield SearchResponse(record=cast(ModelT, record), score=score)
+ except IntegrationException:
+ raise
+ except Exception as exc:
+ raise IntegrationInvalidResponseException(
+ f"Vector search result conversion failed: {exc}"
+ ) from exc
+ except IntegrationException:
+ raise
+ except Exception as exc:
+ raise IntegrationException(f"Vector search iteration failed: {exc}") from exc
+
+ return generate()
+
+ def _meets_score_threshold(
+ self,
+ score: float | None,
+ *,
+ score_threshold: float | None,
+ vector_property_name: str | None,
+ ) -> bool:
+ """Apply a threshold when a result includes a comparable score.
+
+ Results without scores remain included because the threshold cannot be
+ evaluated for them.
+ """
+ if score_threshold is None or score is None:
+ return True
+ vector_field = self.definition.try_get_vector_field(vector_property_name)
+ if vector_field is None or vector_field.distance_function is None:
+ return True
+ comparison = DISTANCE_FUNCTION_DIRECTION_HELPER.get(vector_field.distance_function)
+ return comparison(score, score_threshold) if comparison is not None else True
+
+
+@runtime_checkable
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class SupportsVectorUpsert(Protocol[KeyT, ModelT]):
+ """Protocol for vector collection CRUD operations."""
+
+ collection_name: str
+ record_type: type[ModelT]
+ definition: VectorStoreCollectionDefinition
+
+ async def upsert(
+ self,
+ records: Sequence[ModelT],
+ *,
+ generate_vectors: bool = True,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[KeyT]:
+ """Upsert a batch of records, generating embeddings by default."""
+ ...
+
+ async def get(
+ self,
+ keys: Sequence[KeyT] | None = None,
+ *,
+ top: int = 10,
+ skip: int = 0,
+ order_by: Mapping[str, bool] | None = None,
+ include_vectors: bool = False,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[ModelT]:
+ """Get records by keys or list a page of records, excluding vectors by default."""
+ ...
+
+ async def delete(
+ self,
+ keys: Sequence[KeyT],
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ """Delete a batch of records by key."""
+ ...
+
+
+@runtime_checkable
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+class SupportsVectorSearch(Protocol[ModelT]):
+ """Protocol for vector and keyword-hybrid search."""
+
+ @overload
+ async def search(
+ self,
+ values: Any,
+ *,
+ search_type: SearchType = "vector",
+ vector: Vector | None = None,
+ filter: RecordFilters | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[SearchResponse[ModelT]]:
+ """Search from a value, optionally with a precomputed vector.
+
+ Args:
+ values: The value to search for or vectorize.
+ search_type: Whether to perform vector or keyword-hybrid search.
+ vector: An optional precomputed query vector.
+ filter: One or more lambda filters.
+ top: The maximum number of results.
+ skip: The number of results to skip.
+ include_vectors: Whether returned records include vector fields.
+ vector_property_name: The vector field used for search.
+ additional_property_name: The data field used for keyword-hybrid search.
+ score_threshold: The minimum similarity or maximum distance accepted.
+ Results without scores remain included.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ Lazily consumed search results.
+
+ Raises:
+ ValueError: If paging or search arguments are invalid.
+ NotImplementedError: If the search type is unsupported.
+ IntegrationException: If vector generation or search fails.
+ """
+ ...
+
+ @overload
+ async def search(
+ self,
+ *,
+ search_type: Literal["vector"] = "vector",
+ vector: Vector,
+ filter: RecordFilters | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[SearchResponse[ModelT]]:
+ """Search from a required precomputed vector.
+
+ Args:
+ search_type: The vector search type.
+ vector: The precomputed query vector.
+ filter: One or more lambda filters.
+ top: The maximum number of results.
+ skip: The number of results to skip.
+ include_vectors: Whether returned records include vector fields.
+ vector_property_name: The vector field used for search.
+ additional_property_name: The data field used for keyword-hybrid search.
+ score_threshold: The minimum similarity or maximum distance accepted.
+ Results without scores remain included.
+ operation_options: Store-specific operation options.
+
+ Returns:
+ Lazily consumed search results.
+
+ Raises:
+ ValueError: If paging or search arguments are invalid.
+ NotImplementedError: If vector search is unsupported.
+ IntegrationException: If search execution fails.
+ """
+ ...
+
+
+@experimental(feature_id=ExperimentalFeature.VECTOR_STORES)
+def create_vector_search_tool(
+ search: SupportsVectorSearch[ModelT],
+ *,
+ name: str = _DEFAULT_SEARCH_TOOL_NAME,
+ description: str = _DEFAULT_SEARCH_TOOL_DESCRIPTION,
+ approval_mode: Literal["always_require", "never_require"] = "never_require",
+ search_type: SearchType = "vector",
+ parameters: type[BaseModel] | Mapping[str, Any] | None = None,
+ top: int = 5,
+ skip: int = 0,
+ filter: RecordFilters | None = None,
+ filter_mapper: Callable[[RecordFilters | None, Mapping[str, Any]], RecordFilters | None] | None = None,
+ result_mapper: Callable[[SearchResponse[ModelT]], str | Content | Sequence[Content]] | None = None,
+) -> FunctionTool:
+ """Create an agent-usable tool backed by vector search.
+
+ Args:
+ search: The vector search capability invoked by the tool.
+ name: The tool name.
+ description: The tool description shown to the model.
+ approval_mode: Whether the tool requires approval before invocation.
+ search_type: Whether the tool performs vector or keyword-hybrid search.
+ parameters: A Pydantic model or JSON schema declaring the tool parameters.
+ It must declare ``query`` as a required string. A custom schema can
+ expose ``top`` and ``skip`` as integers with finite ``maximum`` values;
+ additional fields are passed to ``filter_mapper``.
+ top: The default result limit and the maximum when ``parameters`` does not expose ``top``.
+ skip: The default offset and the maximum when ``parameters`` does not expose ``skip``.
+ filter: A fixed filter applied to each tool invocation.
+ filter_mapper: Maps additional declared tool arguments to search filters.
+ The default creates equality filters for each additional argument.
+ result_mapper: Maps each search response to text or one or more multimodal content items.
+
+ Returns:
+ A function tool with only a ``query`` parameter by default. Custom parameters can expose
+ ``top``, ``skip``, and fields mapped into filters by ``filter_mapper``.
+
+ Raises:
+ ValueError: If parameters or paging limits are invalid.
+ NotImplementedError: If the search type is unsupported.
+ """
+ _validate_paging(top=top, skip=skip)
+ map_filter = filter_mapper or _default_search_filter_mapper
+ map_result = result_mapper or _default_search_result_mapper
+ input_model = parameters if parameters is not None else _default_search_tool_parameters()
+ max_top, max_skip = _validate_search_tool_parameters(
+ input_model,
+ default_top=top,
+ default_skip=skip,
+ )
+
+ async def search_tool(**arguments: Any) -> list[Content]:
+ query = arguments.pop("query")
+ if not isinstance(query, str):
+ raise TypeError("The search tool 'query' argument must be a string.")
+ invocation_top = arguments.pop("top", top)
+ invocation_skip = arguments.pop("skip", skip)
+ _validate_paging(top=invocation_top, skip=invocation_skip)
+ if invocation_top > max_top:
+ raise ValueError(f"top must not exceed the configured maximum of {max_top}.")
+ if invocation_skip > max_skip:
+ raise ValueError(f"skip must not exceed the configured maximum of {max_skip}.")
+ dynamic_filter = map_filter(filter, arguments)
+ results = await search.search(
+ query,
+ search_type=search_type,
+ filter=dynamic_filter,
+ top=invocation_top,
+ skip=invocation_skip,
+ )
+ mapped_results: list[Content] = []
+ consumed_results = 0
+ async for result in results:
+ if consumed_results >= invocation_top:
+ break
+ consumed_results += 1
+ mapped = map_result(result)
+ if isinstance(mapped, str):
+ mapped_results.append(Content.from_text(mapped))
+ elif isinstance(mapped, Content):
+ mapped_results.append(mapped)
+ else:
+ mapped_results.extend(mapped)
+ return mapped_results
+
+ return FunctionTool(
+ name=name,
+ description=description,
+ approval_mode=approval_mode,
+ func=search_tool,
+ input_model=input_model,
+ )
+
+
+def _is_non_string_sequence(value: Any) -> TypeGuard[Sequence[Any]]:
+ return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray, Mapping))
+
+
+async def _as_async_iterable(
+ values: AsyncIterable[ResultT] | Sequence[ResultT],
+) -> AsyncIterator[ResultT]:
+ if isinstance(values, AsyncIterable):
+ async for value in values:
+ yield value
+ return
+ for value in values:
+ yield value
+
+
+def _default_search_tool_parameters() -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "The query to search for.",
+ },
+ },
+ "required": ["query"],
+ "additionalProperties": False,
+ }
+
+
+def _validate_search_tool_parameters(
+ parameters: type[BaseModel] | Mapping[str, Any],
+ *,
+ default_top: int,
+ default_skip: int,
+) -> tuple[int, int]:
+ schema: Mapping[str, Any] = parameters.model_json_schema() if isinstance(parameters, type) else parameters
+ raw_properties = schema.get("properties")
+ if not isinstance(raw_properties, Mapping):
+ raise ValueError("Search tool parameters must define object properties.")
+ properties = cast(Mapping[str, Any], raw_properties)
+ query_schema = properties.get("query")
+ required = schema.get("required")
+ query_type = cast(Mapping[str, Any], query_schema).get("type") if isinstance(query_schema, Mapping) else None
+ if (
+ not isinstance(query_schema, Mapping)
+ or query_type != "string"
+ or not _is_non_string_sequence(required)
+ or "query" not in required
+ ):
+ raise ValueError("Search tool parameters must define 'query' as a required string.")
+
+ limits = {"top": default_top, "skip": default_skip}
+ for name, minimum in (("top", 1), ("skip", 0)):
+ parameter_schema = properties.get(name)
+ if parameter_schema is None:
+ continue
+ if not isinstance(parameter_schema, Mapping):
+ raise ValueError(f"Search tool parameter '{name}' must be an integer.")
+ typed_parameter_schema = cast(Mapping[str, Any], parameter_schema)
+ if typed_parameter_schema.get("type") != "integer":
+ raise ValueError(f"Search tool parameter '{name}' must be an integer.")
+ maximum = typed_parameter_schema.get("maximum")
+ if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < minimum:
+ raise ValueError(f"Search tool parameter '{name}' must declare an integer maximum of at least {minimum}.")
+ configured_default = default_top if name == "top" else default_skip
+ if configured_default > maximum:
+ raise ValueError(f"Configured {name}={configured_default} exceeds the parameter maximum of {maximum}.")
+ limits[name] = maximum
+ return limits["top"], limits["skip"]
+
+
+def _default_search_filter_mapper(
+ search_filter: RecordFilters | None,
+ arguments: Mapping[str, Any],
+) -> RecordFilters | None:
+ dynamic_filters: list[RecordFilter] = []
+ for name, value in arguments.items():
+ if not name.isidentifier():
+ raise ValueError(f"Search tool parameter '{name}' cannot be mapped to a model field.")
+ dynamic_filters.append(f"lambda record: record.{name} == {value!r}")
+ if not dynamic_filters:
+ return search_filter
+ if search_filter is None:
+ return dynamic_filters
+ if _is_non_string_sequence(search_filter) and not callable(search_filter):
+ return [*cast(Sequence[RecordFilter], search_filter), *dynamic_filters]
+ return [cast(RecordFilter, search_filter), *dynamic_filters]
+
+
+def _default_search_result_mapper(response: SearchResponse[Any]) -> str:
+ return msgspec.json.encode(
+ response,
+ enc_hook=_msgspec_enc_hook,
+ ).decode()
diff --git a/python/packages/core/tests/core/test_evaluation.py b/python/packages/core/tests/core/test_evaluation.py
new file mode 100644
index 00000000000..f32e216bd9b
--- /dev/null
+++ b/python/packages/core/tests/core/test_evaluation.py
@@ -0,0 +1,368 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Tests for provider-neutral evaluation item construction and splitting."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+from unittest.mock import MagicMock
+
+import pytest
+
+from agent_framework import AgentEvalConverter as ExportedAgentEvalConverter
+from agent_framework._evaluation import AgentEvalConverter, ConversationSplit, EvalItem, _to_eval_item
+from agent_framework._tools import FunctionTool
+from agent_framework._types import AgentResponse, Content, Message
+
+
+class TestAgentEvalConverterCompatibility:
+ def test_root_export_is_legacy_converter(self) -> None:
+ assert ExportedAgentEvalConverter is AgentEvalConverter
+
+ def test_convert_messages_preserves_legacy_foundry_wire_format(self) -> None:
+ messages = [
+ Message("user", ["What's the weather?"]),
+ Message(
+ "assistant",
+ [Content.from_function_call(call_id="call_1", name="get_weather", arguments={"city": "Seattle"})],
+ ),
+ ]
+
+ with pytest.warns(DeprecationWarning, match="AgentEvalConverter"):
+ converted = AgentEvalConverter.convert_messages(messages)
+
+ assert converted == [
+ {"role": "user", "content": [{"type": "text", "text": "What's the weather?"}]},
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "tool_call",
+ "tool_call_id": "call_1",
+ "name": "get_weather",
+ "arguments": {"city": "Seattle"},
+ }
+ ],
+ },
+ ]
+
+ def test_to_eval_item_delegates_to_provider_neutral_builder(self) -> None:
+ response = AgentResponse(messages=[Message("assistant", ["Sunny."])])
+
+ with pytest.warns(DeprecationWarning, match="AgentEvalConverter"):
+ item = AgentEvalConverter.to_eval_item(query="Weather?", response=response)
+
+ assert item.query == "Weather?"
+ assert item.response == "Sunny."
+
+
+class TestToEvalItem:
+ def test_string_query(self) -> None:
+ response = AgentResponse(messages=[Message("assistant", ["The weather is sunny."])])
+ item = _to_eval_item(query="What's the weather?", response=response)
+
+ assert item.query == "What's the weather?"
+ assert item.response == "The weather is sunny."
+ assert [message.role for message in item.conversation] == ["user", "assistant"]
+
+ def test_message_query(self) -> None:
+ input_messages = [
+ Message("system", ["Be helpful."]),
+ Message("user", ["Hello"]),
+ ]
+ response = AgentResponse(messages=[Message("assistant", ["Hi there!"])])
+
+ item = _to_eval_item(query=input_messages, response=response)
+
+ assert item.query == "Hello"
+ assert len(item.conversation) == 3
+
+ def test_with_context(self) -> None:
+ response = AgentResponse(messages=[Message("assistant", ["Answer."])])
+
+ item = _to_eval_item(
+ query="Question?",
+ response=response,
+ context="Some reference document.",
+ )
+
+ assert item.context == "Some reference document."
+
+ def test_with_explicit_tools(self) -> None:
+ def search(query: str) -> str:
+ """Search the web."""
+ return f"Results for {query}"
+
+ response = AgentResponse(messages=[Message("assistant", ["Found it."])])
+
+ item = _to_eval_item(query="Find info", response=response, tools=[search])
+
+ assert item.tools is not None
+ assert len(item.tools) == 1
+ assert item.tools[0].name == "search"
+
+ def test_with_agent_and_mcp_tools(self) -> None:
+ agent_tool = FunctionTool(name="calculate", description="Calculate", func=lambda value: str(value))
+ mcp_tool = FunctionTool(name="search", description="Search", func=lambda query: query)
+ agent = MagicMock()
+ agent.default_options = {"tools": [agent_tool]}
+ agent.mcp_tools = [MagicMock(functions=[agent_tool, mcp_tool])]
+ response = AgentResponse(messages=[Message("assistant", ["Done"])])
+
+ item = _to_eval_item(query="Research this", response=response, agent=agent)
+
+ assert item.tools == [agent_tool, mcp_tool]
+
+ def test_explicit_tools_override_agent(self) -> None:
+ agent_tool = FunctionTool(name="agent_tool", description="from agent", func=lambda: "")
+ explicit_tool = FunctionTool(name="explicit_tool", description="explicit", func=lambda: "")
+ agent = MagicMock()
+ agent.default_options = {"tools": [agent_tool]}
+ response = AgentResponse(messages=[Message("assistant", ["Done"])])
+
+ item = _to_eval_item(
+ query="Test",
+ response=response,
+ agent=agent,
+ tools=[explicit_tool],
+ )
+
+ assert item.tools == [explicit_tool]
+
+
+class TestEvalItemSplitting:
+ def test_split_messages_format(self) -> None:
+ tool = FunctionTool(name="test", description="Test", func=lambda: "")
+ item = _to_eval_item(
+ query="Q",
+ response=AgentResponse(messages=[Message("assistant", ["Answer"])]),
+ tools=[tool],
+ )
+
+ query_messages, response_messages = item.split_messages()
+
+ assert [message.role for message in query_messages] == ["user"]
+ assert [message.role for message in response_messages] == ["assistant"]
+ assert item.tools == [tool]
+
+ def test_multiturn_preserves_interleaving(self) -> None:
+ conversation = [
+ Message("user", ["What's the weather?"]),
+ Message("assistant", ["It's sunny in Seattle."]),
+ Message("user", ["And tomorrow?"]),
+ Message("assistant", [Content(type="function_call", name="get_forecast")]),
+ Message("tool", [Content(type="function_result", result="Rain expected")]),
+ Message("assistant", ["Rain is expected tomorrow."]),
+ ]
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages()
+
+ assert [message.role for message in query_messages] == ["user", "assistant", "user"]
+ assert [message.role for message in response_messages] == ["assistant", "tool", "assistant"]
+
+ def test_full_split(self) -> None:
+ conversation = [
+ Message("user", ["What's the weather?"]),
+ Message("assistant", ["It's 62°F in Seattle."]),
+ Message("user", ["And tomorrow?"]),
+ Message("assistant", ["Rain is expected tomorrow."]),
+ ]
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages(
+ split=cast(Any, ConversationSplit.FULL)
+ )
+
+ assert [message.text for message in query_messages] == ["What's the weather?"]
+ assert [message.role for message in response_messages] == ["assistant", "user", "assistant"]
+
+ def test_full_split_includes_system_message(self) -> None:
+ conversation = [
+ Message("system", ["You are a weather assistant."]),
+ Message("user", ["What's the weather?"]),
+ Message("assistant", ["It's sunny."]),
+ ]
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages(
+ split=cast(Any, ConversationSplit.FULL)
+ )
+
+ assert [message.role for message in query_messages] == ["system", "user"]
+ assert [message.role for message in response_messages] == ["assistant"]
+
+ def test_full_split_puts_tool_interactions_in_response(self) -> None:
+ conversation = [
+ Message("user", ["What's the weather?"]),
+ Message("assistant", [Content(type="function_call", name="get_weather")]),
+ Message("tool", [Content(type="function_result", result="62°F")]),
+ Message("assistant", ["It's 62°F."]),
+ Message("user", ["Thanks!"]),
+ Message("assistant", ["You're welcome!"]),
+ ]
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages(
+ split=cast(Any, ConversationSplit.FULL)
+ )
+
+ assert len(query_messages) == 1
+ assert len(response_messages) == 5
+
+ def test_last_turn_is_default(self) -> None:
+ conversation = [
+ Message("user", ["Hello"]),
+ Message("assistant", ["Hi there"]),
+ Message("user", ["Bye"]),
+ Message("assistant", ["Goodbye"]),
+ ]
+ item = EvalItem(conversation=conversation)
+
+ default_query, default_response = item.split_messages()
+ explicit_query, explicit_response = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN))
+
+ assert default_query == explicit_query
+ assert default_response == explicit_response
+
+ def test_per_turn_items(self) -> None:
+ conversation = [
+ Message("user", ["What's the weather?"]),
+ Message("assistant", ["It's 62°F."]),
+ Message("user", ["And tomorrow?"]),
+ Message("assistant", ["Rain expected."]),
+ ]
+
+ items = EvalItem.per_turn_items(conversation)
+
+ assert len(items) == 2
+ assert (items[0].query, items[0].response) == ("What's the weather?", "It's 62°F.")
+ assert (items[1].query, items[1].response) == ("What's the weather? And tomorrow?", "Rain expected.")
+ assert [len(item.conversation) for item in items] == [2, 4]
+
+ def test_per_turn_items_preserve_tools(self) -> None:
+ conversation = [
+ Message("user", ["Check weather"]),
+ Message("assistant", [Content(type="function_call", name="get_weather")]),
+ Message("tool", [Content(type="function_result", result="sunny")]),
+ Message("assistant", ["It's sunny."]),
+ Message("user", ["Thanks"]),
+ Message("assistant", ["You're welcome!"]),
+ ]
+ tool = FunctionTool(name="get_weather", description="Get weather")
+
+ items = EvalItem.per_turn_items(conversation, tools=[tool])
+
+ assert len(items) == 2
+ assert items[0].tools == [tool]
+ assert items[0].response == "It's sunny."
+ assert items[1].response == "You're welcome!"
+
+ def test_per_turn_items_without_user_messages(self) -> None:
+ assert EvalItem.per_turn_items([Message("assistant", ["Hello"])]) == []
+
+ def test_per_turn_items_single_turn(self) -> None:
+ items = EvalItem.per_turn_items([
+ Message("user", ["Hi"]),
+ Message("assistant", ["Hello!"]),
+ ])
+
+ assert len(items) == 1
+ assert (items[0].query, items[0].response) == ("Hi", "Hello!")
+
+ def test_custom_splitter_callable(self) -> None:
+ conversation = [
+ Message("user", ["Remember my name is Alice"]),
+ Message("assistant", ["Got it, Alice!"]),
+ Message("user", ["What's the capital of France?"]),
+ Message("assistant", [Content(type="function_call", name="retrieve_memory", call_id="m1")]),
+ Message("tool", [Content(type="function_result", call_id="m1", result="User name: Alice")]),
+ Message("assistant", ["The capital of France is Paris, Alice!"]),
+ ]
+
+ def split_before_memory(messages: list[Message]) -> tuple[list[Message], list[Message]]:
+ for index, message in enumerate(messages):
+ if any(content.name == "retrieve_memory" for content in message.contents):
+ return messages[:index], messages[index:]
+ return EvalItem._split_last_turn_static(messages)
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages(
+ split=cast(Any, split_before_memory)
+ )
+
+ assert len(query_messages) == 3
+ assert query_messages[-1].role == "user"
+ assert len(response_messages) == 3
+ assert response_messages[0].role == "assistant"
+
+ def test_custom_splitter_fallback(self) -> None:
+ conversation = [
+ Message("user", ["Hello"]),
+ Message("assistant", ["Hi there!"]),
+ ]
+
+ def split_before_memory(messages: list[Message]) -> tuple[list[Message], list[Message]]:
+ for index, message in enumerate(messages):
+ if any(content.name == "retrieve_memory" for content in message.contents):
+ return messages[:index], messages[index:]
+ return EvalItem._split_last_turn_static(messages)
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages(
+ split=cast(Any, split_before_memory)
+ )
+
+ assert [message.role for message in query_messages] == ["user"]
+ assert [message.role for message in response_messages] == ["assistant"]
+
+ def test_custom_splitter_lambda(self) -> None:
+ conversation = [
+ Message("user", ["A"]),
+ Message("assistant", ["B"]),
+ Message("user", ["C"]),
+ Message("assistant", ["D"]),
+ ]
+
+ query_messages, response_messages = EvalItem(conversation=conversation).split_messages(
+ split=cast(Any, lambda messages: (messages[:2], messages[2:]))
+ )
+
+ assert len(query_messages) == 2
+ assert len(response_messages) == 2
+
+ def test_item_split_strategy_is_default(self) -> None:
+ conversation = [
+ Message("user", ["First"]),
+ Message("assistant", ["Response 1"]),
+ Message("user", ["Second"]),
+ Message("assistant", ["Response 2"]),
+ ]
+ item = EvalItem(conversation=conversation, split_strategy=cast(Any, ConversationSplit.FULL))
+
+ query_messages, response_messages = item.split_messages()
+
+ assert [message.text for message in query_messages] == ["First"]
+ assert len(response_messages) == 3
+
+ def test_explicit_split_overrides_item_strategy(self) -> None:
+ conversation = [
+ Message("user", ["First"]),
+ Message("assistant", ["Response 1"]),
+ Message("user", ["Second"]),
+ Message("assistant", ["Response 2"]),
+ ]
+ item = EvalItem(conversation=conversation, split_strategy=cast(Any, ConversationSplit.FULL))
+
+ query_messages, response_messages = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN))
+
+ assert len(query_messages) == 3
+ assert query_messages[-1].text == "Second"
+ assert len(response_messages) == 1
+
+ def test_no_split_defaults_to_last_turn(self) -> None:
+ item = EvalItem(
+ conversation=[
+ Message("user", ["Hello"]),
+ Message("assistant", ["Hi"]),
+ ]
+ )
+
+ query_messages, _ = item.split_messages()
+
+ assert item.split_strategy is None
+ assert [message.role for message in query_messages] == ["user"]
diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py
index ee52c3eaf4d..f93727edb28 100644
--- a/python/packages/core/tests/core/test_mcp.py
+++ b/python/packages/core/tests/core/test_mcp.py
@@ -31,6 +31,7 @@
)
from agent_framework._feature_stage import _WARNED_FEATURES, ExperimentalFeature, ExperimentalWarning
from agent_framework._mcp import (
+ _MCP_HEADER_OWNER_EXTENSION,
MCPTool,
_build_prefixed_mcp_name,
_describe_error,
@@ -65,6 +66,16 @@ def _reset_progressive_mcp_warning_state() -> None:
_WARNED_FEATURES.discard((ExperimentalWarning, ExperimentalFeature.PROGRESSIVE_TOOLS.value))
+def _request_for_mcp_tool(tool: MCPStreamableHTTPTool, url: str = "http://example.com/mcp") -> Any:
+ import httpx
+
+ return httpx.Request(
+ "POST",
+ url,
+ extensions={_MCP_HEADER_OWNER_EXTENSION: tool._header_request_owner},
+ )
+
+
# Helper function tests
def test_normalize_mcp_name():
"""Test MCP name normalization."""
@@ -4070,7 +4081,7 @@ async def test_load_prompts_prevents_multiple_calls():
async def test_mcp_streamable_http_tool_httpx_client_cleanup():
- """Test that MCPStreamableHTTPTool properly passes through httpx clients."""
+ """Test that MCPStreamableHTTPTool delegates to caller-provided httpx clients."""
from unittest.mock import AsyncMock, Mock, patch
from agent_framework import MCPStreamableHTTPTool
@@ -4120,10 +4131,10 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup():
# Verify the user-provided client was stored
assert tool2._httpx_client is user_client, "User-provided client should be stored"
- # Verify streamable_http_client was called with the user's client
+ # Verify the transport wrapper delegates to the user's client.
# Get the last call (should be from tool2.connect())
call_args = mock_client.call_args
- assert call_args.kwargs["http_client"] is user_client, "User's client should be passed through"
+ assert call_args.kwargs["http_client"]._client is user_client
async def test_load_tools_with_pagination():
@@ -6135,8 +6146,6 @@ def get_mcp_client(self): # pyrefly: ignore[bad-override]
async def test_mcp_streamable_http_tool_header_provider_with_httpx_event_hook():
"""Test that the httpx event hook injects headers from the contextvar."""
- import httpx
-
from agent_framework._mcp import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT, _mcp_call_headers
tool = MCPStreamableHTTPTool(
@@ -6161,7 +6170,7 @@ async def test_mcp_streamable_http_tool_header_provider_with_httpx_event_hook():
# Simulate what happens during a call_tool: contextvar is set
token = _mcp_call_headers.set({"X-Custom": "test-value"})
try:
- request = httpx.Request("POST", "http://example.com/mcp")
+ request = _request_for_mcp_tool(tool)
await hooks[0](request)
assert request.headers.get("X-Custom") == "test-value"
finally:
@@ -6179,8 +6188,6 @@ async def test_mcp_streamable_http_tool_header_provider_injects_on_ambient_reque
outside call_tool, so the contextvar/snapshot are unset. A static header_provider should
still be invoked (with empty kwargs) so these requests carry auth headers.
"""
- import httpx
-
tool = MCPStreamableHTTPTool(
name="test",
url="http://example.com/mcp",
@@ -6196,7 +6203,7 @@ async def test_mcp_streamable_http_tool_header_provider_injects_on_ambient_reque
assert len(hooks) == 1
# No contextvar set and no active call snapshot: simulates the initialize handshake.
- request = httpx.Request("POST", "http://example.com/mcp")
+ request = _request_for_mcp_tool(tool)
await hooks[0](request)
assert request.headers.get("Authorization") == "******"
finally:
@@ -6211,8 +6218,6 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_request_tolerate
time raise KeyError. The hook should swallow that specific error and proceed without headers
rather than failing the initialize handshake.
"""
- import httpx
-
tool = MCPStreamableHTTPTool(
name="test",
url="http://example.com/mcp",
@@ -6228,7 +6233,7 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_request_tolerate
assert len(hooks) == 1
# No kwargs available at connect time -> provider raises KeyError -> hook swallows it.
- request = httpx.Request("POST", "http://example.com/mcp")
+ request = _request_for_mcp_tool(tool)
await hooks[0](request)
assert "Authorization" not in request.headers
finally:
@@ -6244,8 +6249,6 @@ async def test_mcp_streamable_http_tool_header_provider_empty_active_call_skips_
rather than as "unset", which would re-invoke header_provider({}) mid-call and inject headers
the caller deliberately omitted.
"""
- import httpx
-
from agent_framework._mcp import _mcp_call_headers
call_count = 0
@@ -6271,7 +6274,7 @@ def provider(kw: dict[str, Any]) -> dict[str, str]:
tool._active_call_headers = {}
try:
call_count = 0
- request = httpx.Request("POST", "http://example.com/mcp")
+ request = _request_for_mcp_tool(tool)
await hooks[0](request)
assert "Authorization" not in request.headers
assert call_count == 0, "ambient fallback must not run during a set-but-empty call"
@@ -6292,8 +6295,6 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_kwarg_error_is_b
"""
import logging
- import httpx
-
tool = MCPStreamableHTTPTool(
name="test",
url="http://example.com/mcp",
@@ -6310,7 +6311,7 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_kwarg_error_is_b
with caplog.at_level(logging.DEBUG, logger="agent_framework._mcp"):
for _ in range(3):
- request = httpx.Request("POST", "http://example.com/mcp")
+ request = _request_for_mcp_tool(tool)
await hooks[0](request)
assert "Authorization" not in request.headers
@@ -6328,7 +6329,6 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_non_keyerror_pro
failure or a provider bug - propagates so it is not silently converted into unauthenticated
traffic, matching the call_tool path which does not catch header_provider exceptions.
"""
- import httpx
class TokenRefreshError(RuntimeError):
pass
@@ -6347,7 +6347,7 @@ def failing_provider(kw: dict[str, Any]) -> dict[str, str]:
assert len(hooks) == 1
with pytest.raises(TokenRefreshError):
- await hooks[0](httpx.Request("POST", "http://example.com/mcp"))
+ await hooks[0](_request_for_mcp_tool(tool))
finally:
if getattr(tool, "_httpx_client", None) is not None:
await tool._httpx_client.aclose() # type: ignore[union-attr]
@@ -6362,7 +6362,10 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir
tool = MCPStreamableHTTPTool(
name="test",
url="http://example.com/mcp",
- header_provider=lambda kw: {"Authorization": f"Bearer {kw.get('token', '')}"},
+ header_provider=lambda kw: {
+ "Authorization": f"Bearer {kw.get('token', '')}",
+ "X-API-Key": kw.get("api_key", ""),
+ },
)
try:
@@ -6373,15 +6376,22 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir
hooks = tool._httpx_client.event_hooks.get("request", [])
assert len(hooks) == 1
- token = _mcp_call_headers.set({"Authorization": "Bearer secret"})
+ token = _mcp_call_headers.set({"Authorization": "Bearer secret", "X-API-Key": "api-secret"})
try:
- same_origin = httpx.Request("POST", "http://example.com/redirected")
+ same_origin = _request_for_mcp_tool(tool, "http://example.com/redirected")
await hooks[0](same_origin)
assert same_origin.headers.get("Authorization") == "Bearer secret"
+ assert same_origin.headers.get("X-API-Key") == "api-secret"
- cross_origin = httpx.Request("POST", "http://attacker.example/capture")
+ cross_origin = httpx.Request(
+ "POST",
+ "http://attacker.example/capture",
+ headers=same_origin.headers,
+ extensions=same_origin.extensions,
+ )
await hooks[0](cross_origin)
assert "Authorization" not in cross_origin.headers
+ assert "X-API-Key" not in cross_origin.headers
finally:
_mcp_call_headers.reset(token)
finally:
@@ -6389,6 +6399,54 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir
await tool._httpx_client.aclose() # type: ignore[union-attr]
+async def test_mcp_streamable_http_tool_replaces_headers_on_same_origin_redirect():
+ """A redirected request must retain only the provider's latest header set."""
+ import httpx
+
+ provider_headers = {"X-Previous": "old"}
+ tool = MCPStreamableHTTPTool(
+ name="test",
+ url="http://example.com/mcp",
+ header_provider=lambda _kw: provider_headers,
+ )
+
+ try:
+ with patch("agent_framework._mcp.streamable_http_client"):
+ tool.get_mcp_client()
+
+ assert tool._httpx_client is not None
+ hooks = tool._httpx_client.event_hooks.get("request", [])
+ assert len(hooks) == 1
+
+ initial = _request_for_mcp_tool(tool, "http://example.com/start")
+ await hooks[0](initial)
+ assert initial.headers.get("X-Previous") == "old"
+
+ provider_headers = {"X-Current": "new"}
+ same_origin_redirect = httpx.Request(
+ "POST",
+ "http://example.com/redirected",
+ headers=initial.headers,
+ extensions=initial.extensions,
+ )
+ await hooks[0](same_origin_redirect)
+ assert "X-Previous" not in same_origin_redirect.headers
+ assert same_origin_redirect.headers.get("X-Current") == "new"
+
+ cross_origin_redirect = httpx.Request(
+ "POST",
+ "http://other.example/redirected",
+ headers=same_origin_redirect.headers,
+ extensions=same_origin_redirect.extensions,
+ )
+ await hooks[0](cross_origin_redirect)
+ assert "X-Previous" not in cross_origin_redirect.headers
+ assert "X-Current" not in cross_origin_redirect.headers
+ finally:
+ if getattr(tool, "_httpx_client", None) is not None:
+ await tool._httpx_client.aclose() # type: ignore[union-attr]
+
+
async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client():
"""Test that header_provider works when the user provides their own httpx client."""
import httpx
@@ -6415,7 +6473,7 @@ async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client()
# Verify the hook injects headers
token = _mcp_call_headers.set({"X-Dynamic": "per-request"})
try:
- request = httpx.Request("POST", "http://example.com/mcp")
+ request = _request_for_mcp_tool(tool)
await hooks[0](request)
assert request.headers.get("X-Dynamic") == "per-request"
finally:
@@ -6424,6 +6482,232 @@ async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client()
await user_client.aclose()
+async def test_mcp_streamable_http_tool_header_provider_isolated_on_shared_httpx_client():
+ """Each MCP transport must use its own headers when sharing an httpx client."""
+ import httpx
+
+ captured_headers: list[dict[str, str]] = []
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ captured_headers.append({key.lower(): value for key, value in request.headers.items()})
+ return httpx.Response(200)
+
+ user_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ tool_a = MCPStreamableHTTPTool(
+ name="a",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"Authorization": "Bearer A", "X-Principal-A": "present"},
+ )
+ tool_b = MCPStreamableHTTPTool(
+ name="b",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"Authorization": "Bearer B", "X-Principal-B": "present"},
+ )
+ tool_without_provider = MCPStreamableHTTPTool(
+ name="anonymous",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ )
+
+ try:
+ with patch("agent_framework._mcp.streamable_http_client") as mock_transport:
+ tool_a.get_mcp_client()
+ client_a = mock_transport.call_args.kwargs["http_client"]
+ tool_b.get_mcp_client()
+ client_b = mock_transport.call_args.kwargs["http_client"]
+ tool_without_provider.get_mcp_client()
+ client_without_provider = mock_transport.call_args.kwargs["http_client"]
+
+ for client in (client_a, client_b, client_without_provider, client_a):
+ async with client.stream("POST", "http://example.com/mcp"):
+ pass
+
+ assert captured_headers[0].get("authorization") == "Bearer A"
+ assert captured_headers[0].get("x-principal-a") == "present"
+ assert "x-principal-b" not in captured_headers[0]
+ assert captured_headers[1].get("authorization") == "Bearer B"
+ assert captured_headers[1].get("x-principal-b") == "present"
+ assert "x-principal-a" not in captured_headers[1]
+ assert "authorization" not in captured_headers[2]
+ assert "x-principal-a" not in captured_headers[2]
+ assert "x-principal-b" not in captured_headers[2]
+ assert captured_headers[3].get("authorization") == "Bearer A"
+ assert captured_headers[3].get("x-principal-a") == "present"
+ assert "x-principal-b" not in captured_headers[3]
+ finally:
+ await user_client.aclose()
+
+
+async def test_mcp_streamable_http_tool_removes_header_hook_on_close():
+ """Closing one tool must remove only its hook, and reconnecting must restore it."""
+ import httpx
+
+ user_client = httpx.AsyncClient()
+ tool_a = MCPStreamableHTTPTool(
+ name="a",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"Authorization": "Bearer A"},
+ )
+ tool_b = MCPStreamableHTTPTool(
+ name="b",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"Authorization": "Bearer B"},
+ )
+
+ try:
+ with patch("agent_framework._mcp.streamable_http_client"):
+ tool_a.get_mcp_client()
+ tool_b.get_mcp_client()
+ assert len(user_client.event_hooks["request"]) == 2
+
+ await tool_a.close()
+ assert user_client.event_hooks["request"] == [tool_b._inject_headers_hook]
+
+ # Reconnecting after close re-attaches exactly one hook for tool A.
+ with patch("agent_framework._mcp.streamable_http_client"):
+ tool_a.get_mcp_client()
+ tool_a.get_mcp_client()
+ assert user_client.event_hooks["request"].count(tool_a._inject_headers_hook) == 1
+ assert len(user_client.event_hooks["request"]) == 2
+ finally:
+ await user_client.aclose()
+
+
+async def test_mcp_streamable_http_tool_removes_hook_without_mutating_active_hook_list():
+ """Closing one tool must not disrupt an in-progress iteration over shared hooks."""
+ import httpx
+
+ from agent_framework._mcp import _MCP_INJECTED_HEADER_KEYS_EXTENSION
+
+ captured_headers: list[httpx.Headers] = []
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ captured_headers.append(request.headers)
+ return httpx.Response(200)
+
+ user_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ tool_a = MCPStreamableHTTPTool(
+ name="a",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"Authorization": "Bearer token-a"},
+ )
+ tool_b = MCPStreamableHTTPTool(
+ name="b",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"X-API-Key": "current"},
+ )
+ hook_started = asyncio.Event()
+ allow_hook = asyncio.Event()
+
+ async def delayed_hook(_request: httpx.Request) -> None:
+ hook_started.set()
+ await allow_hook.wait()
+
+ send_task: asyncio.Task[httpx.Response] | None = None
+ try:
+ with patch("agent_framework._mcp.streamable_http_client"):
+ tool_a.get_mcp_client()
+ tool_b.get_mcp_client()
+ user_client.event_hooks["request"].insert(1, delayed_hook)
+
+ request = httpx.Request(
+ "POST",
+ "http://other.example/redirected",
+ headers={"X-API-Key": "previous"},
+ extensions={
+ _MCP_HEADER_OWNER_EXTENSION: tool_b._header_request_owner,
+ _MCP_INJECTED_HEADER_KEYS_EXTENSION: ("X-API-Key",),
+ },
+ )
+ send_task = asyncio.create_task(user_client.send(request))
+ await hook_started.wait()
+
+ await tool_a.close()
+ allow_hook.set()
+ await send_task
+
+ assert len(captured_headers) == 1
+ assert "X-API-Key" not in captured_headers[0]
+ finally:
+ allow_hook.set()
+ if send_task is not None:
+ await send_task
+ await user_client.aclose()
+
+
+async def test_mcp_streamable_http_tool_keeps_header_hook_until_cancelled_close_finishes():
+ """Caller cancellation must not remove the hook while lifecycle teardown continues."""
+ import httpx
+
+ user_client = httpx.AsyncClient()
+ tool = MCPStreamableHTTPTool(
+ name="test",
+ url="http://example.com/mcp",
+ http_client=user_client,
+ header_provider=lambda _kw: {"Authorization": "Bearer token"},
+ )
+ close_started = asyncio.Event()
+ allow_close = asyncio.Event()
+ # Recorded rather than asserted here: an assertion raised on the lifecycle owner task
+ # is swallowed by its error handling, so it would pass even when the hook is detached.
+ hook_attached_during_teardown: list[bool] = []
+
+ async def delayed_close() -> None:
+ close_started.set()
+ await allow_close.wait()
+ hook_attached_during_teardown.append(tool._inject_headers_hook in user_client.event_hooks["request"])
+
+ try:
+ with patch("agent_framework._mcp.streamable_http_client"):
+ tool.get_mcp_client()
+ assert tool._inject_headers_hook in user_client.event_hooks["request"]
+
+ with patch.object(MCPTool, "_close_on_owner", side_effect=delayed_close):
+ close_task = asyncio.create_task(tool.close())
+ await close_started.wait()
+ owner_task = tool._lifecycle_owner_task
+ assert owner_task is not None
+
+ close_task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await close_task
+ assert tool._inject_headers_hook in user_client.event_hooks["request"]
+
+ allow_close.set()
+ await owner_task
+
+ assert hook_attached_during_teardown == [True]
+ assert tool._inject_headers_hook not in user_client.event_hooks["request"]
+ finally:
+ allow_close.set()
+ owner_task = tool._lifecycle_owner_task
+ if owner_task is not None:
+ await owner_task
+ await user_client.aclose()
+
+
+async def test_mcp_header_scoped_client_delegates_unwrapped_attributes():
+ """The transport wrapper must stay a drop-in for the caller's httpx client."""
+ import httpx
+
+ from agent_framework._mcp import _MCPHeaderScopedClient
+
+ user_client = httpx.AsyncClient(headers={"X-Base": "static"}, follow_redirects=True)
+ try:
+ wrapper = _MCPHeaderScopedClient(user_client, object())
+ assert wrapper.headers["X-Base"] == "static"
+ assert wrapper.follow_redirects is True
+ assert wrapper.build_request("POST", "http://example.com/mcp").method == "POST"
+ finally:
+ await user_client.aclose()
+
+
async def test_mcp_streamable_http_tool_header_provider_via_invoke_with_context():
"""Test that header_provider receives kwargs via FunctionTool.invoke with FunctionInvocationContext.
diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py
new file mode 100644
index 00000000000..6875ea8e9da
--- /dev/null
+++ b/python/packages/core/tests/core/test_mcp_http_auth.py
@@ -0,0 +1,315 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+from collections.abc import AsyncGenerator, AsyncIterator
+from typing import Any
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from agent_framework import MCPStreamableHTTPTool
+from agent_framework.exceptions import ToolException
+
+
+@pytest.fixture
+async def mcp_http_server() -> AsyncIterator[tuple[httpx.AsyncClient, list[httpx.Request], dict[str, list[str]]]]:
+ requests: list[httpx.Request] = []
+ writes: dict[str, list[str]] = {"token-a": [], "token-b": [], "token-c": []}
+
+ async def record_request(request: httpx.Request) -> None:
+ requests.append(request)
+
+ async def handle(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/unrelated":
+ return httpx.Response(200)
+ principal = request.headers.get("Authorization", "")
+ if principal not in writes:
+ return httpx.Response(401)
+ if request.method == "GET":
+ return httpx.Response(405)
+ if request.method == "DELETE":
+ return httpx.Response(200)
+ body = json.loads(request.content)
+ method = body.get("method")
+ headers: dict[str, str] = {}
+ result: dict[str, Any] = {}
+ if method == "initialize":
+ headers["mcp-session-id"] = f"session-{principal}"
+ result = {
+ "protocolVersion": body["params"]["protocolVersion"],
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "auth-test", "version": "1"},
+ }
+ elif method == "tools/list":
+ result = {
+ "tools": [
+ {
+ "name": "record",
+ "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}},
+ }
+ ]
+ }
+ elif method == "tools/call":
+ await asyncio.sleep(0)
+ marker = body["params"].get("arguments", {}).get("marker")
+ if marker is not None:
+ writes[principal].append(marker)
+ result = {"content": [{"type": "text", "text": principal}]}
+ if "id" not in body:
+ return httpx.Response(202)
+ return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result})
+
+ async with httpx.AsyncClient(
+ transport=httpx.MockTransport(handle), event_hooks={"request": [record_request]}
+ ) as client:
+ yield client, requests, writes
+
+
+def _tool(client: httpx.AsyncClient, principal: str) -> MCPStreamableHTTPTool:
+ return MCPStreamableHTTPTool(
+ name=principal,
+ url="https://mcp.example/mcp",
+ http_client=client,
+ load_prompts=False,
+ header_provider=lambda kwargs: {"Authorization": kwargs.get("credential", principal)},
+ )
+
+
+def _calls(requests: list[httpx.Request]) -> list[httpx.Request]:
+ return [
+ request
+ for request in requests
+ if request.method == "POST" and json.loads(request.content).get("method") == "tools/call"
+ ]
+
+
+@pytest.mark.parametrize("principals", [("token-a", "token-b"), ("token-b", "token-a")])
+async def test_shared_client_keeps_tools_and_caller_requests_isolated(mcp_http_server, principals):
+ client, requests, writes = mcp_http_server
+ original_hooks = list(client.event_hooks["request"])
+ first, second = (_tool(client, principal) for principal in principals)
+
+ async with first:
+ await first.call_tool("record")
+ async with second:
+ await second.call_tool("record")
+ await first.call_tool("record", marker="first-only")
+ await client.get("https://mcp.example/unrelated")
+ assert "Authorization" not in requests[-1].headers
+ await first.call_tool("record")
+ await first.connect(reset=True)
+ await first.call_tool("record")
+ assert len(client.event_hooks["request"]) == len(original_hooks) + 1
+
+ calls = _calls(requests)
+ assert [request.headers["Authorization"] for request in calls] == [
+ principals[0],
+ principals[1],
+ principals[0],
+ principals[0],
+ principals[0],
+ ]
+ assert calls[2].headers["mcp-session-id"] == f"session-{principals[0]}"
+ assert all(
+ request.headers["mcp-session-id"] == f"session-{request.headers['Authorization']}"
+ for request in requests
+ if "mcp-session-id" in request.headers
+ )
+ assert writes[principals[0]] == ["first-only"]
+ assert writes[principals[1]] == []
+ assert client.event_hooks["request"] == original_hooks
+ assert not client.is_closed
+ await client.get("https://mcp.example/unrelated")
+ assert "Authorization" not in requests[-1].headers
+
+
+async def test_closing_another_tool_does_not_skip_inflight_request_hooks(mcp_http_server):
+ client, requests, _ = mcp_http_server
+ started = asyncio.Event()
+ release = asyncio.Event()
+
+ async def pause_call(request: httpx.Request) -> None:
+ if request.method == "POST" and json.loads(request.content).get("method") == "tools/call":
+ started.set()
+ await release.wait()
+
+ async with _tool(client, "token-a") as first:
+ client.event_hooks["request"].append(pause_call)
+ async with _tool(client, "token-b") as second:
+ call = asyncio.create_task(second.call_tool("record"))
+ try:
+ await asyncio.wait_for(started.wait(), timeout=5)
+ await first.close()
+ finally:
+ release.set()
+ await call
+ assert _calls(requests)[-1].headers["Authorization"] == "token-b"
+ assert pause_call in client.event_hooks["request"]
+
+
+@pytest.mark.parametrize("failure", ["entry", "cancellation", "initialize"])
+@pytest.mark.parametrize("owned_client", [False, True])
+async def test_transport_failure_cleans_up_hooks_and_owned_client(mcp_http_server, failure, owned_client):
+ client, _, _ = mcp_http_server
+ original_hooks = list(client.event_hooks["request"])
+ tool = _tool(client, "token-a")
+ if owned_client:
+ tool = MCPStreamableHTTPTool(
+ name="owned", url="https://mcp.example/mcp", header_provider=lambda _: {"Authorization": "token-a"}
+ )
+ if failure == "initialize":
+ tool = MCPStreamableHTTPTool(
+ name="invalid",
+ url="https://mcp.example/mcp",
+ http_client=None if owned_client else client,
+ header_provider=lambda _: {"Authorization": "invalid-token"},
+ )
+
+ @contextlib.asynccontextmanager
+ async def transport(**kwargs: Any) -> AsyncGenerator[tuple[()]]:
+ if failure == "cancellation":
+ task = asyncio.current_task()
+ assert task is not None
+ task.cancel()
+ await asyncio.sleep(0)
+ raise RuntimeError("transport entry failed")
+ yield ()
+
+ error = asyncio.CancelledError if failure == "cancellation" else ToolException
+ transport_patch = (
+ contextlib.nullcontext()
+ if failure == "initialize"
+ else patch("agent_framework._mcp.streamable_http_client", side_effect=transport)
+ )
+ try:
+ with transport_patch, patch("httpx.AsyncClient", return_value=client), pytest.raises(error):
+ await tool.connect()
+ assert client.event_hooks["request"] == original_hooks
+ assert client.is_closed is owned_client
+ finally:
+ await tool.close()
+
+
+async def test_owned_client_is_closed_after_successful_session(mcp_http_server):
+ client, _, _ = mcp_http_server
+ original_hooks = list(client.event_hooks["request"])
+ tool = MCPStreamableHTTPTool(
+ name="owned",
+ url="https://mcp.example/mcp",
+ load_prompts=False,
+ header_provider=lambda _: {"Authorization": "token-a"},
+ )
+ with patch("httpx.AsyncClient", return_value=client):
+ async with tool:
+ await tool.call_tool("record")
+ assert client.is_closed
+ assert client.event_hooks["request"] == original_hooks
+
+
+async def test_connecting_another_tool_during_a_call_does_not_capture_its_headers(mcp_http_server):
+ client, requests, _ = mcp_http_server
+ second = _tool(client, "token-b")
+ connected = False
+
+ async def connect_second(request: httpx.Request) -> None:
+ nonlocal connected
+ if not connected and request.method == "POST" and json.loads(request.content).get("method") == "tools/call":
+ connected = True
+ await second.connect()
+
+ client.event_hooks["request"].append(connect_second)
+ try:
+ async with _tool(client, "token-a") as first:
+ await first.call_tool("record", credential="token-c")
+ await second.call_tool("record")
+ assert [request.headers["Authorization"] for request in _calls(requests)] == ["token-c", "token-b"]
+ second_initializes = [
+ request
+ for request in requests
+ if request.method == "POST"
+ and json.loads(request.content).get("method") == "initialize"
+ and request.headers["Authorization"] == "token-b"
+ ]
+ assert len(second_initializes) == 1
+ finally:
+ await second.close()
+ assert not client.is_closed
+ await client.get("https://mcp.example/unrelated")
+ assert "Authorization" not in requests[-1].headers
+
+
+async def test_shared_client_concurrent_calls_keep_dynamic_headers_isolated(mcp_http_server):
+ client, requests, writes = mcp_http_server
+ async with _tool(client, "token-a") as first, _tool(client, "token-b") as second:
+ await asyncio.gather(
+ first.call_tool("record", credential="token-c", marker="first"),
+ second.call_tool("record", credential="token-b", marker="second"),
+ )
+ calls = _calls(requests)
+ assert {request.headers["mcp-session-id"]: request.headers["Authorization"] for request in calls} == {
+ "session-token-a": "token-c",
+ "session-token-b": "token-b",
+ }
+ assert writes == {"token-a": [], "token-b": ["second"], "token-c": ["first"]}
+ assert all("credential" not in json.loads(request.content)["params"]["arguments"] for request in calls)
+
+
+async def test_headerless_transport_does_not_inherit_another_transports_credentials(mcp_http_server):
+ client, requests, _ = mcp_http_server
+ second = MCPStreamableHTTPTool(
+ name="unauthenticated", url="https://mcp.example/mcp", http_client=client, load_prompts=False
+ )
+ attempted = False
+
+ async def connect_second(request: httpx.Request) -> None:
+ nonlocal attempted
+ if not attempted and request.method == "POST" and json.loads(request.content).get("method") == "tools/call":
+ attempted = True
+ with pytest.raises(ToolException):
+ await second.connect()
+
+ client.event_hooks["request"].append(connect_second)
+ try:
+ async with _tool(client, "token-a") as first:
+ await first.call_tool("record")
+ assert attempted
+ assert _calls(requests)[-1].headers["Authorization"] == "token-a"
+ assert any(
+ request.method == "POST"
+ and json.loads(request.content).get("method") == "initialize"
+ and "Authorization" not in request.headers
+ for request in requests
+ )
+ finally:
+ await second.close()
+
+
+async def test_failed_connect_removes_only_its_own_authentication_hook(mcp_http_server):
+ client, requests, _ = mcp_http_server
+ async with _tool(client, "token-a") as first:
+ original_hooks = list(client.event_hooks["request"])
+ failed = _tool(client, "invalid-token")
+ try:
+ with pytest.raises(ToolException):
+ await failed.connect()
+ assert client.event_hooks["request"] == original_hooks
+ await first.call_tool("record")
+ assert _calls(requests)[-1].headers["Authorization"] == "token-a"
+ finally:
+ await failed.close()
+ assert not client.is_closed
+
+
+async def test_prepared_transport_hook_is_removed_on_close(mcp_http_server):
+ client, _, _ = mcp_http_server
+ original_hooks = list(client.event_hooks["request"])
+ tool = _tool(client, "token-a")
+ tool.get_mcp_client()
+ tool.get_mcp_client()
+ await tool.close()
+ assert client.event_hooks["request"] == original_hooks
diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py
index 03853e83868..d31685c8d68 100644
--- a/python/packages/core/tests/core/test_serializable_mixin.py
+++ b/python/packages/core/tests/core/test_serializable_mixin.py
@@ -3,7 +3,9 @@
"""Tests for SerializationMixin functionality."""
import copy
+import json
import logging
+from datetime import date, datetime, time
from typing import Any
import pytest
@@ -304,19 +306,82 @@ def __init__(self, items_dict: dict):
assert data["items_dict"]["a"]["name"] == "item1"
assert data["items_dict"]["b"]["name"] == "item2"
- def test_to_dict_with_datetime_in_dict(self):
- """Test to_dict converts datetime objects in dicts to strings."""
- from datetime import datetime
+ def test_to_dict_recursively_serializes_nested_containers(self):
+ """Test to_dict serializes protocol objects nested in containers."""
+
+ class ItemClass(SerializationMixin):
+ def __init__(self, name: str):
+ self.name = name
+
+ class ContainerClass(SerializationMixin):
+ def __init__(self, payload: dict):
+ self.payload = payload
+
+ container = ContainerClass(payload={"groups": [{"items": [ItemClass(name="item1")]}]})
+
+ data = container.to_dict()
+
+ assert data["payload"]["groups"][0]["items"][0]["name"] == "item1"
+ assert json.loads(container.to_json()) == data
+
+ def test_to_dict_preserves_nested_non_string_dict_keys(self):
+ """Test recursive serialization preserves non-string keys below the attribute dictionary."""
+
+ class ContainerClass(SerializationMixin):
+ def __init__(self, payload: dict):
+ self.payload = payload
+
+ container = ContainerClass(payload={7: "direct", "nested": {True: "enabled", None: "missing"}})
+
+ data = container.to_dict()
+
+ assert data["payload"]["7"] == "direct"
+ assert data["payload"]["nested"] == {True: "enabled", None: "missing"}
+ json_payload = json.loads(container.to_json())["payload"]
+ assert json_payload["7"] == "direct"
+ assert json_payload["nested"] == {"true": "enabled", "null": "missing"}
+
+ def test_to_dict_rejects_circular_list(self):
+ """Test recursive serialization reports a controlled error for a circular list."""
+
+ class ContainerClass(SerializationMixin):
+ def __init__(self, payload: list[Any]):
+ self.payload = payload
+
+ payload: list[Any] = []
+ payload.append(payload)
+
+ with pytest.raises(ValueError, match="Circular reference detected"):
+ ContainerClass(payload).to_dict()
+
+ def test_to_dict_rejects_circular_dict(self):
+ """Test recursive serialization reports a controlled error for a circular dictionary."""
+
+ class ContainerClass(SerializationMixin):
+ def __init__(self, payload: dict[str, Any]):
+ self.payload = payload
+
+ payload: dict[str, Any] = {}
+ payload["self"] = payload
+
+ with pytest.raises(ValueError, match="Circular reference detected"):
+ ContainerClass(payload).to_dict()
+
+ @pytest.mark.parametrize("value", [datetime(2025, 1, 27, 12), date(2025, 1, 27), time(12)])
+ def test_to_dict_only_converts_date_time_in_dict_values(self, value):
+ """Test to_dict preserves the existing date/time conversion contexts."""
class TestClass(SerializationMixin):
- def __init__(self, metadata: dict):
- self.metadata = metadata
+ def __init__(self):
+ self.top_level = value
+ self.items = [value]
+ self.metadata = {"created_at": value}
- now = datetime(2025, 1, 27, 12, 0, 0)
- obj = TestClass(metadata={"created_at": now})
- data = obj.to_dict()
+ data = TestClass().to_dict()
- assert isinstance(data["metadata"]["created_at"], str)
+ assert "top_level" not in data
+ assert data["items"] == []
+ assert data["metadata"]["created_at"] == str(value)
def test_to_dict_skips_non_serializable_in_dict(self, caplog):
"""Test to_dict skips non-serializable values in dicts with debug logging."""
@@ -572,6 +637,61 @@ def __init__(self, items: list, opaque: Any = None, additional_properties: dict
assert cloned.items is not obj.items
assert cloned.items == ["a"]
+ def test_shallow_copy_preserves_pickle_omitted_fields(self):
+ """Shallow copies retain runtime fields that pickle omits."""
+
+ class TestClass(SerializationMixin):
+ def __init__(self, raw_representation: Any):
+ self.raw_representation = raw_representation
+
+ raw = object()
+ cloned = copy.copy(TestClass(raw))
+
+ assert cloned.raw_representation is raw
+
+ def test_pickle_restores_slot_fields(self):
+ """Pickle state should include fields declared in slots."""
+
+ class TestClass(SerializationMixin):
+ __slots__ = ("value",)
+
+ def __init__(self, value: str):
+ self.value = value
+
+ original = TestClass("value")
+ restored = TestClass.__new__(TestClass)
+ restored.__setstate__(original.__getstate__())
+
+ assert restored.value == "value"
+
+ def test_pickle_restores_legacy_tuple_state(self):
+ """Pickle restoration should accept the legacy dict-and-slots tuple."""
+
+ class TestClass(SerializationMixin):
+ __slots__ = ("value",)
+
+ def __init__(self):
+ self.value = "new"
+
+ restored = TestClass.__new__(TestClass)
+ restored.__setstate__(({"other": "dict"}, {"value": "legacy"}))
+
+ assert restored.value == "legacy"
+
+ def test_pickle_omission_is_separate_from_shallow_copy_policy(self):
+ """Fields shallow-copied by default remain persistent unless explicitly omitted."""
+
+ class TestClass(SerializationMixin):
+ _PICKLE_OMIT_FIELDS = set()
+
+ def __init__(self, raw_representation: Any):
+ self.raw_representation = raw_representation
+
+ raw = {"provider": "value"}
+ state = TestClass(raw).__getstate__()
+
+ assert state["raw_representation"] == raw
+
def test_dependency_dict_merge_does_not_mutate_input(self):
"""Test that dict dependency merging does not mutate the caller's input dictionary."""
diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py
index a4d5957f587..e936da808af 100644
--- a/python/packages/core/tests/core/test_types.py
+++ b/python/packages/core/tests/core/test_types.py
@@ -2733,6 +2733,29 @@ def test_content_deepcopy_discards_raw_representation(caplog: pytest.LogCaptureF
assert caplog.messages == ["Discarding field 'raw_representation' while deep-copying Content."]
+def test_content_pickle_discards_nested_annotation_raw_representation() -> None:
+ """Pickle should omit provider objects stored on annotations."""
+ import pickle
+
+ raw = object()
+ annotation: Annotation = {"type": "citation", "url": "https://example.com", "raw_representation": raw}
+ content = Content.from_text("hello", annotations=[annotation])
+
+ restored = pickle.loads(pickle.dumps(content))
+
+ assert restored.annotations == [{"type": "citation", "url": "https://example.com"}]
+
+
+def test_content_shallow_copy_preserves_raw_representation() -> None:
+ """Shallow copies of Content retain provider runtime fields."""
+ import copy
+
+ raw = _NonCopyableRaw()
+ cloned = copy.copy(Content.from_text("hello", raw_representation=raw))
+
+ assert cloned.raw_representation is raw
+
+
def test_message_deepcopy_preserves_raw_representation():
"""Test that deepcopy of Message keeps raw_representation by reference."""
import copy
diff --git a/python/packages/core/tests/core/test_vectors.py b/python/packages/core/tests/core/test_vectors.py
new file mode 100644
index 00000000000..9bb045e61d7
--- /dev/null
+++ b/python/packages/core/tests/core/test_vectors.py
@@ -0,0 +1,1373 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+import warnings
+from ast import AST, unparse
+from collections.abc import AsyncIterable, Mapping, Sequence
+from dataclasses import FrozenInstanceError, dataclass, field
+from typing import Annotated, Any, ClassVar, cast
+from unittest.mock import patch
+
+import msgspec
+import pytest
+from pydantic import BaseModel
+from pydantic import Field as PydanticField
+from typing_extensions import TypeVar
+
+from agent_framework import (
+ DISTANCE_FUNCTION_DIRECTION_HELPER,
+ BaseEmbeddingClient,
+ BaseVectorCollection,
+ BaseVectorSearch,
+ BaseVectorStore,
+ Content,
+ DistanceFunction,
+ Embedding,
+ EmbeddingGenerationOptions,
+ ExperimentalFeature,
+ FieldTypes,
+ GeneratedEmbeddings,
+ IndexKind,
+ SearchResponse,
+ SearchResults,
+ SearchType,
+ SupportsVectorSearch,
+ SupportsVectorUpsert,
+ VectorStoreCollectionDefinition,
+ VectorStoreField,
+ create_vector_search_tool,
+ register_vectorstoremodel,
+ vectorstoremodel,
+)
+from agent_framework._feature_stage import ExperimentalWarning
+from agent_framework._telemetry import FeatureIndex
+from agent_framework._vectors import _VectorStoreRecordHandler as VectorStoreRecordHandler
+from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException
+
+pytestmark = pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning")
+
+with warnings.catch_warnings():
+ warnings.simplefilter("ignore", ExperimentalWarning)
+ RecordVector = Annotated[
+ str | list[float] | None,
+ VectorStoreField(
+ "vector",
+ dimensions=2,
+ index_kind="hnsw",
+ distance_function="cosine_similarity",
+ ),
+ ]
+
+ @vectorstoremodel(collection_name="records")
+ @dataclass
+ class Record:
+ id: Annotated[str, VectorStoreField("key", storage_name="record_id")]
+ text: Annotated[str, VectorStoreField("data", storage_name="body", is_full_text_indexed=True)]
+ vector: RecordVector = None
+ category: str = "general"
+
+
+class MockEmbeddingClient(BaseEmbeddingClient):
+ def __init__(self) -> None:
+ super().__init__()
+ self.values: list[Any] = []
+ self.options: EmbeddingGenerationOptions | None = None
+
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[list[float]]:
+ self.values = list(values)
+ self.options = options
+ return GeneratedEmbeddings([Embedding(vector=[float(len(str(value))), 0.5]) for value in values])
+
+
+class MockCollection(BaseVectorCollection[str, Record], BaseVectorSearch[str, Record]):
+ supported_key_types: ClassVar[set[str] | None] = {"str"}
+ supported_vector_types: ClassVar[set[str] | None] = {"float"}
+ supported_search_types: ClassVar[set[SearchType]] = {"vector", "keyword_hybrid"}
+
+ def __init__(self, *, embedding_generator: MockEmbeddingClient | None = None) -> None:
+ super().__init__(Record, embedding_generator=embedding_generator)
+ self.created = False
+ self.records: dict[str, dict[str, Any]] = {}
+ self.last_search_type: str | None = None
+ self.last_search_vector: Sequence[float | int] | None = None
+ self.last_search_filter: Any | list[Any] | None = None
+ self.last_search_top = 0
+ self.last_search_skip = 0
+ self.fail_upsert = False
+ self.upsert_error: Exception | None = None
+ self.get_error: Exception | None = None
+ self.delete_error: Exception | None = None
+ self.search_error: Exception | None = None
+ self.upsert_keys: Sequence[str] | None = None
+ self.raw_search_results: AsyncIterable[Any] | Sequence[Any] | None = None
+
+ async def ensure_collection_exists(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ self.created = True
+
+ async def collection_exists(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> bool:
+ return self.created
+
+ async def ensure_collection_deleted(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ self.created = False
+ self.records.clear()
+
+ async def _inner_upsert(
+ self,
+ records: Sequence[Any],
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[str]:
+ if self.upsert_error is not None:
+ raise self.upsert_error
+ if self.fail_upsert:
+ raise RuntimeError("store unavailable")
+ keys: list[str] = []
+ for record in records:
+ mapping = cast(Mapping[str, Any], record)
+ key = cast(str, mapping["record_id"])
+ self.records[key] = dict(mapping)
+ keys.append(key)
+ return self.upsert_keys if self.upsert_keys is not None else keys
+
+ async def _inner_get(
+ self,
+ *,
+ keys: Sequence[str] | None = None,
+ top: int = 10,
+ skip: int = 0,
+ order_by: Mapping[str, bool] | None = None,
+ include_vectors: bool = False,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[Any] | None:
+ if self.get_error is not None:
+ raise self.get_error
+ if keys is not None:
+ return [self.records[key] for key in keys if key in self.records]
+ return list(self.records.values())[skip : skip + top]
+
+ async def _inner_delete(
+ self,
+ keys: Sequence[str],
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ if self.delete_error is not None:
+ raise self.delete_error
+ for key in keys:
+ self.records.pop(key, None)
+
+ async def _inner_search(
+ self,
+ *,
+ search_type: SearchType,
+ filter: Any | list[Any] | None = None,
+ values: Any | None = None,
+ vector: Sequence[float | int] | None = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[Any]:
+ if self.search_error is not None:
+ raise self.search_error
+ self.last_search_type = search_type
+ self.last_search_vector = vector
+ self.last_search_filter = filter
+ self.last_search_top = top
+ self.last_search_skip = skip
+ raw_results = self.raw_search_results or [
+ {"record": record, "score": score} for record, score in zip(self.records.values(), (0.9, 0.4), strict=False)
+ ]
+ return SearchResults(raw_results, metadata={"mock_count": len(self.records)})
+
+ def _get_record_from_result(self, result: Any) -> Any:
+ return result["record"]
+
+ def _get_score_from_result(self, result: Any) -> float | None:
+ return cast(float | None, result["score"])
+
+ def _lambda_parser(self, node: AST) -> str:
+ return unparse(node)
+
+
+StoreModelT = TypeVar("StoreModelT")
+
+
+class MockStore(BaseVectorStore):
+ def __init__(self, collection: MockCollection) -> None:
+ super().__init__()
+ self.collection = collection
+
+ def get_collection(
+ self,
+ record_type: type[StoreModelT],
+ *,
+ definition: VectorStoreCollectionDefinition | None = None,
+ collection_name: str | None = None,
+ embedding_generator: Any | None = None,
+ ) -> BaseVectorCollection[Any, StoreModelT]:
+ return cast(BaseVectorCollection[Any, StoreModelT], self.collection)
+
+ async def list_collection_names(
+ self,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> Sequence[str]:
+ return [self.collection.collection_name] if self.collection.created else []
+
+ async def _inner_ensure_collection_deleted(
+ self,
+ collection_name: str,
+ *,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> None:
+ assert collection_name == self.collection.collection_name
+ await self.collection.ensure_collection_deleted(operation_options=operation_options)
+
+
+def test_vector_literal_types_and_distance_directions() -> None:
+ field_type: FieldTypes = "vector"
+ index_kind: IndexKind = "hnsw"
+ distance_function: DistanceFunction = "cosine_similarity"
+
+ assert field_type == "vector"
+ assert index_kind == "hnsw"
+ assert distance_function == "cosine_similarity"
+ assert DISTANCE_FUNCTION_DIRECTION_HELPER["cosine_similarity"](0.5, 0.5)
+ assert DISTANCE_FUNCTION_DIRECTION_HELPER["cosine_distance"](0.5, 0.5)
+ assert not DISTANCE_FUNCTION_DIRECTION_HELPER["cosine_distance"](0.6, 0.5)
+
+
+def test_vector_apis_are_marked_experimental() -> None:
+ staged_apis = (
+ VectorStoreField,
+ VectorStoreCollectionDefinition,
+ vectorstoremodel,
+ SearchResponse,
+ SearchResults,
+ BaseVectorCollection,
+ BaseVectorStore,
+ BaseVectorSearch,
+ register_vectorstoremodel,
+ )
+ for api in staged_apis:
+ assert getattr(api, "__feature_stage__", None) == "experimental"
+ assert getattr(api, "__feature_id__", None) == ExperimentalFeature.VECTOR_STORES.value
+ assert ".. warning:: Experimental" in (api.__doc__ or "")
+
+ staged_protocols = (
+ SupportsVectorUpsert,
+ SupportsVectorSearch,
+ )
+ for protocol in staged_protocols:
+ assert ".. warning:: Experimental" in (protocol.__doc__ or "")
+
+
+def test_vector_field_validates_vector_options() -> None:
+ with pytest.raises(ValueError, match="positive"):
+ cast(Any, VectorStoreField)("vector")
+ with pytest.raises(ValueError, match="Vector-only"):
+ cast(Any, VectorStoreField)("data", dimensions=3)
+ with pytest.raises(ValueError, match="index kind"):
+ cast(Any, VectorStoreField)("vector", dimensions=3, index_kind="unknown")
+ with pytest.raises(ValueError, match="distance function"):
+ cast(Any, VectorStoreField)("vector", dimensions=3, distance_function="unknown")
+
+
+def test_collection_definition_exposes_fields() -> None:
+ definition = cast(VectorStoreCollectionDefinition, vars(Record)["__vectorstoremodel_definition__"])
+
+ assert definition.collection_name == "records"
+ assert definition.key_name == "id"
+ assert definition.key_field_storage_name == "record_id"
+ assert definition.names == ["id", "text", "vector"]
+ assert definition.storage_names == ["record_id", "body", "vector"]
+ assert definition.data_field_names == ["text"]
+ assert definition.vector_field_names == ["vector"]
+ assert definition.get_names(include_vector_fields=False) == ["id", "text"]
+ assert definition.get_storage_names(include_key_field=False) == ["body", "vector"]
+ assert isinstance(definition.fields, tuple)
+ assert definition.vector_fields[0].dimensions == 2
+ assert definition.vector_fields[0].index_kind == "hnsw"
+ assert definition.vector_fields[0].distance_function == "cosine_similarity"
+
+ frozen_field = cast(Any, definition.fields[0])
+ with pytest.raises(FrozenInstanceError):
+ frozen_field.name = "changed"
+ frozen_definition = cast(Any, definition)
+ with pytest.raises(FrozenInstanceError):
+ frozen_definition.fields = ()
+
+
+@pytest.mark.parametrize(
+ "fields, message",
+ [
+ ([], "at least one"),
+ ([VectorStoreField("data", name="text")], "exactly one key"),
+ (
+ [
+ VectorStoreField("key", name="id"),
+ VectorStoreField("key", name="other_id"),
+ ],
+ "exactly one key",
+ ),
+ (
+ [
+ VectorStoreField("key", name="id"),
+ VectorStoreField("data", name="id"),
+ ],
+ "must be unique",
+ ),
+ ],
+)
+def test_collection_definition_rejects_invalid_fields(
+ fields: list[VectorStoreField],
+ message: str,
+) -> None:
+ with pytest.raises(ValueError, match=message):
+ VectorStoreCollectionDefinition(fields)
+
+
+def test_vectorstoremodel_supports_pydantic_models() -> None:
+ @vectorstoremodel
+ class PydanticRecord(BaseModel):
+ id: Annotated[str, VectorStoreField("key")]
+ vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None
+
+ definition = cast(
+ VectorStoreCollectionDefinition,
+ vars(PydanticRecord)["__vectorstoremodel_definition__"],
+ )
+ assert vars(PydanticRecord)["__vectorstoremodel__"]
+ assert definition.key_field.type_ == "str"
+ assert definition.vector_fields[0].type_ == "float"
+ handler = VectorStoreRecordHandler(PydanticRecord)
+ record = handler.deserialize({"id": "one", "vector": [1.0, 0.0]}, include_vectors=False)
+ assert isinstance(record, PydanticRecord)
+ assert record.vector is None
+
+
+def test_vectorstoremodel_supports_plain_classes() -> None:
+ @vectorstoremodel
+ class PlainRecord:
+ def __init__(
+ self,
+ id: Annotated[str, VectorStoreField("key")],
+ text: Annotated[str, VectorStoreField("data")],
+ ) -> None:
+ self.id = id
+ self.text = text
+
+ definition = cast(
+ VectorStoreCollectionDefinition,
+ vars(PlainRecord)["__vectorstoremodel_definition__"],
+ )
+ assert definition.names == ["id", "text"]
+
+
+def test_vectorstoremodel_ignores_fields_with_defaults() -> None:
+ assert (
+ "category"
+ not in cast(
+ VectorStoreCollectionDefinition,
+ vars(Record)["__vectorstoremodel_definition__"],
+ ).names
+ )
+
+
+def test_vectorstoremodel_detects_factory_and_required_slotted_defaults() -> None:
+ @vectorstoremodel
+ @dataclass(slots=True)
+ class FactoryRecord:
+ id: Annotated[str, VectorStoreField("key")]
+ ignored: list[str] = field(default_factory=list)
+
+ assert (
+ "ignored"
+ not in cast(
+ VectorStoreCollectionDefinition,
+ vars(FactoryRecord)["__vectorstoremodel_definition__"],
+ ).names
+ )
+
+ class InvalidStruct(msgspec.Struct):
+ id: Annotated[str, VectorStoreField("key")]
+ required_but_unmapped: str
+
+ with pytest.raises(ValueError, match="required_but_unmapped"):
+ vectorstoremodel(InvalidStruct)
+
+ class RequiredVector(msgspec.Struct):
+ id: Annotated[str, VectorStoreField("key")]
+ vector: Annotated[list[float], VectorStoreField("vector", dimensions=2)]
+
+ with pytest.raises(ValueError, match="must declare defaults"):
+ vectorstoremodel(RequiredVector)
+
+
+def test_vectorstoremodel_rejects_required_unmapped_fields() -> None:
+ class InvalidRecord:
+ id: Annotated[str, VectorStoreField("key")]
+ required_but_unmapped: str
+
+ with pytest.raises(ValueError, match="required_but_unmapped"):
+ vectorstoremodel(InvalidRecord)
+ assert not hasattr(InvalidRecord, "__vectorstoremodel__")
+
+
+async def test_collection_and_search_validate_paging() -> None:
+ with pytest.raises(ValueError, match="greater than zero"):
+ await MockCollection().get(top=0)
+ with pytest.raises(ValueError, match="negative"):
+ await MockCollection().search("query", skip=-1)
+
+
+def test_record_handler_validates_connector_field_types() -> None:
+ class IntKeyHandler(VectorStoreRecordHandler[str, Record]):
+ supported_key_types: ClassVar[set[str] | None] = {"int"}
+
+ with pytest.raises(ValueError, match="Key field type"):
+ IntKeyHandler(Record)
+
+
+async def test_record_handler_serializes_dict_records_with_explicit_definition() -> None:
+ definition = VectorStoreCollectionDefinition([
+ VectorStoreField("key", name="id", storage_name="record_id"),
+ VectorStoreField("data", name="text", storage_name="body"),
+ ])
+ handler = VectorStoreRecordHandler(dict, definition=definition)
+
+ serialized = await handler.serialize({"id": "one", "text": "hello"})
+ assert serialized == {"record_id": "one", "body": "hello"}
+ assert handler.deserialize(serialized) == {"id": "one", "text": "hello"}
+ assert handler.deserialize([]) == []
+
+ with pytest.raises(IntegrationInvalidResponseException, match="missing required field 'body'"):
+ handler.deserialize({"record_id": "one"})
+ assert handler.deserialize({"record_id": "one", "body": None}) == {"id": "one", "text": None}
+
+ with pytest.raises(ValueError, match="missing.*text"):
+ await handler.serialize({"id": "missing-text"})
+
+
+async def test_batch_serializer_preserves_cardinality() -> None:
+ class DroppingHandler(VectorStoreRecordHandler[Any, Record]):
+ def _serialize_dicts_to_store_models(
+ self,
+ records: Sequence[dict[str, Any]],
+ *,
+ context: Mapping[str, Any] | None = None,
+ ) -> Sequence[Any]:
+ return records[:-1]
+
+ with pytest.raises(IntegrationInvalidResponseException, match="Expected 2 serialized records"):
+ await DroppingHandler(Record).serialize(
+ [
+ Record("one", "first"),
+ Record("two", "second"),
+ ],
+ generate_vectors=False,
+ )
+
+
+async def test_record_handler_supports_msgspec_structs() -> None:
+ @vectorstoremodel
+ class MsgspecRecord(msgspec.Struct):
+ id: Annotated[str, VectorStoreField("key")]
+ vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None
+
+ handler = VectorStoreRecordHandler(MsgspecRecord)
+ serialized = await handler.serialize(MsgspecRecord("one", [1.0, 0.0]), generate_vectors=False)
+ deserialized = handler.deserialize(serialized)
+
+ assert serialized == {"id": "one", "vector": [1.0, 0.0]}
+ assert deserialized == MsgspecRecord("one", [1.0, 0.0])
+
+
+async def test_record_handler_uses_registered_codecs() -> None:
+ @dataclass
+ class CustomRecord:
+ id: str
+ text: str
+
+ definition = VectorStoreCollectionDefinition(
+ [
+ VectorStoreField("key", name="id", storage_name="record_id"),
+ VectorStoreField("data", name="text", storage_name="body"),
+ ],
+ )
+ register_vectorstoremodel(
+ CustomRecord,
+ definition=definition,
+ encoder=lambda record: {"id": record.id, "text": record.text.upper()},
+ decoder=lambda record: CustomRecord(**record),
+ )
+ handler = VectorStoreRecordHandler(CustomRecord)
+
+ serialized = await handler.serialize(CustomRecord("one", "hello"))
+ assert serialized == {"record_id": "one", "body": "HELLO"}
+ assert handler.deserialize(serialized) == CustomRecord("one", "HELLO")
+
+
+async def test_register_vectorstoremodel_supports_independent_encoder_override() -> None:
+ @dataclass
+ class RegisteredRecord:
+ id: str = ""
+
+ definition = VectorStoreCollectionDefinition([VectorStoreField("key", name="id")])
+
+ def encoder(record: RegisteredRecord) -> Mapping[str, Any]:
+ return {"id": record.id}
+
+ register_vectorstoremodel(RegisteredRecord, definition=definition, encoder=encoder)
+ handler = VectorStoreRecordHandler(RegisteredRecord)
+ assert await handler.serialize(RegisteredRecord("one")) == {"id": "one"}
+ assert handler.deserialize({"id": "one"}) == RegisteredRecord("one")
+
+ with pytest.raises(ValueError, match="another definition"):
+ register_vectorstoremodel(
+ RegisteredRecord,
+ definition=VectorStoreCollectionDefinition([VectorStoreField("key", name="other_id")]),
+ )
+
+
+async def test_array_like_vectors_round_trip_without_array_dependency() -> None:
+ class ArrayLike:
+ __slots__ = ("values",)
+
+ def __init__(self, values: list[float]) -> None:
+ self.values = values
+
+ def tolist(self) -> list[float]:
+ return self.values
+
+ def decode_array_record(record: Mapping[str, Any]) -> ArrayRecord:
+ return ArrayRecord(
+ id=cast(str, record["id"]),
+ vector=ArrayLike(cast(list[float], record["vector"])),
+ )
+
+ @vectorstoremodel(decoder=decode_array_record)
+ @dataclass
+ class ArrayRecord:
+ id: Annotated[str, VectorStoreField("key")]
+ vector: Annotated[Any, VectorStoreField("vector", dimensions=3)]
+
+ handler = VectorStoreRecordHandler(ArrayRecord)
+ serialized = await handler.serialize(
+ ArrayRecord("one", ArrayLike([0.1, 0.2, 0.3])),
+ generate_vectors=False,
+ )
+ restored = handler.deserialize(serialized)
+
+ assert serialized == {"id": "one", "vector": [0.1, 0.2, 0.3]}
+ assert isinstance(restored, ArrayRecord)
+ assert restored.vector.values == [0.1, 0.2, 0.3]
+
+
+async def test_custom_encoder_normalizes_array_like_vectors() -> None:
+ class ArrayLike:
+ def tolist(self) -> list[float]:
+ return [0.1, 0.2, 0.3]
+
+ @dataclass
+ class CustomArrayRecord:
+ id: str
+ vector: ArrayLike
+
+ definition = VectorStoreCollectionDefinition([
+ VectorStoreField("key", name="id"),
+ VectorStoreField("vector", name="vector", dimensions=3),
+ ])
+ register_vectorstoremodel(
+ CustomArrayRecord,
+ definition=definition,
+ encoder=lambda record: {"id": record.id, "vector": record.vector},
+ decoder=lambda record: CustomArrayRecord(
+ id=cast(str, record["id"]),
+ vector=ArrayLike(),
+ ),
+ )
+
+ serialized = await VectorStoreRecordHandler(CustomArrayRecord).serialize(
+ CustomArrayRecord("one", ArrayLike()),
+ generate_vectors=False,
+ )
+ assert serialized == {"id": "one", "vector": [0.1, 0.2, 0.3]}
+
+
+async def test_pydantic_aliases_round_trip_by_field_name() -> None:
+ @vectorstoremodel
+ class AliasedRecord(BaseModel):
+ id: Annotated[str, PydanticField(alias="record_id"), VectorStoreField("key")]
+
+ handler = VectorStoreRecordHandler(AliasedRecord)
+ serialized = await handler.serialize(AliasedRecord.model_validate({"record_id": "one"}))
+ restored = handler.deserialize(serialized)
+
+ assert serialized == {"id": "one"}
+ assert isinstance(restored, AliasedRecord)
+ assert restored.id == "one"
+
+
+async def test_collection_serializes_records_and_generates_vectors() -> None:
+ embedding_client = MockEmbeddingClient()
+ collection = MockCollection(embedding_generator=embedding_client)
+
+ serialized = await collection.serialize(Record("one", "hello", "embed this"))
+
+ assert serialized == {
+ "record_id": "one",
+ "body": "hello",
+ "vector": [10.0, 0.5],
+ }
+ assert embedding_client.values == ["embed this"]
+ assert embedding_client.options == {"dimensions": 2}
+
+
+async def test_upsert_controls_embedding_generation() -> None:
+ embedding_client = MockEmbeddingClient()
+ collection = MockCollection(embedding_generator=embedding_client)
+
+ await collection.upsert([Record("generated", "text", [1.0, 0.0])])
+
+ assert embedding_client.values == [[1.0, 0.0]]
+ assert collection.records["generated"]["vector"] == [10.0, 0.5]
+
+ embedding_client.values.clear()
+ await collection.upsert(
+ [Record("preserved", "text", [1.0, 0.0])],
+ generate_vectors=False,
+ )
+
+ assert embedding_client.values == []
+ assert collection.records["preserved"]["vector"] == [1.0, 0.0]
+
+ with pytest.raises(ValueError, match="has no embedding generator.*generate_vectors=False"):
+ await MockCollection().upsert([Record("missing-generator", "text", [1.0, 0.0])])
+
+
+async def test_collection_crud_preserves_single_and_batch_shapes() -> None:
+ collection = MockCollection(embedding_generator=MockEmbeddingClient())
+ await collection.ensure_collection_exists()
+
+ first_keys = await collection.upsert([Record("one", "first", "first")])
+ keys = await collection.upsert([
+ Record("two", "second", "second"),
+ Record("three", "third", "third"),
+ ])
+ one = await collection.get(["one"])
+ many = await collection.get(["one", "two"], include_vectors=True)
+ filtered = await collection.get(top=1)
+
+ assert first_keys == ["one"]
+ assert keys == ["two", "three"]
+ assert one == [Record("one", "first")]
+ assert many == [
+ Record("one", "first", [5.0, 0.5]),
+ Record("two", "second", [6.0, 0.5]),
+ ]
+ assert filtered == [Record("one", "first")]
+
+ await collection.delete(["one", "two"])
+ assert await collection.get(["one", "two"]) == []
+
+
+async def test_collection_wraps_connector_errors() -> None:
+ collection = MockCollection()
+ collection.fail_upsert = True
+
+ with pytest.raises(IntegrationException, match="store unavailable"):
+ await collection.upsert([Record("one", "hello")], generate_vectors=False)
+
+
+async def test_collection_get_without_keys_lists_records() -> None:
+ assert await MockCollection().get() == []
+
+
+async def test_collection_crud_rejects_singular_ordinary_inputs() -> None:
+ collection = MockCollection()
+
+ with pytest.raises(TypeError, match="records must be a sequence"):
+ await cast(Any, collection.upsert)(Record("one", "hello"))
+ with pytest.raises(TypeError, match="keys must be a sequence"):
+ await collection.get("one")
+ with pytest.raises(TypeError, match="keys must be a sequence"):
+ await collection.delete("one")
+
+
+async def test_vector_search_generates_query_vector_and_filters_threshold() -> None:
+ embedding_client = MockEmbeddingClient()
+ collection = MockCollection(embedding_generator=embedding_client)
+ await collection.upsert([
+ Record("one", "first", "first"),
+ Record("two", "second", "second"),
+ ])
+
+ results = await collection.search(
+ "find this",
+ score_threshold=0.5,
+ )
+ responses = [response async for response in results]
+
+ assert results.metadata == {"mock_count": 2}
+ assert embedding_client.values == ["find this"]
+ assert collection.last_search_vector == [9.0, 0.5]
+ assert responses[0]["record"].id == "one"
+ assert responses[0]["score"] == 0.9
+ assert len(responses) == 1
+
+
+async def test_keyword_hybrid_search_uses_single_search_method() -> None:
+ collection = MockCollection()
+
+ await collection.search("words", search_type="keyword_hybrid")
+
+ assert collection.last_search_type == "keyword_hybrid"
+
+
+async def test_vector_search_validates_inputs_and_supported_type() -> None:
+ collection = MockCollection()
+
+ with pytest.raises(ValueError, match="requires values"):
+ await cast(Any, collection.search)()
+
+ class VectorOnlyCollection(MockCollection):
+ supported_search_types: ClassVar[set[SearchType]] = {"vector"}
+
+ with pytest.raises(NotImplementedError, match="not supported"):
+ await VectorOnlyCollection().search("words", search_type="keyword_hybrid")
+
+
+async def test_vector_search_requires_explicit_distance_for_score_threshold() -> None:
+ collection = MockCollection()
+ collection.definition = VectorStoreCollectionDefinition(
+ [
+ VectorStoreField("key", name="id", type_="str"),
+ VectorStoreField("vector", name="vector", type_="float", dimensions=2),
+ ],
+ collection_name="records",
+ )
+
+ with pytest.raises(ValueError, match="explicit distance"):
+ await collection.search(vector=[1.0, 0.0], score_threshold=0.5)
+
+
+async def test_vector_search_wraps_embedding_failures() -> None:
+ class FailingEmbeddingClient(MockEmbeddingClient):
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[list[float]]:
+ raise RuntimeError("embedding unavailable")
+
+ collection = MockCollection(embedding_generator=FailingEmbeddingClient())
+
+ with pytest.raises(IntegrationException, match="embedding unavailable"):
+ await collection.search("query")
+
+
+def test_vector_search_builds_connector_filter() -> None:
+ collection = MockCollection()
+
+ assert collection._build_filter("lambda record: record.category == 'travel'") == "record.category == 'travel'"
+ assert collection._build_filter([
+ "lambda record: record.category == 'travel'",
+ "lambda record: record.id != 'ignored'",
+ ]) == ["record.category == 'travel'", "record.id != 'ignored'"]
+
+
+async def test_vector_search_passes_translated_filter_to_connector() -> None:
+ collection = MockCollection()
+
+ await collection.search(
+ "query",
+ filter="lambda record: record.category == 'travel'",
+ )
+
+ assert collection.last_search_filter == "record.category == 'travel'"
+
+
+def test_vector_search_rejects_filter_without_lambda() -> None:
+ with pytest.raises(ValueError, match="No lambda"):
+ MockCollection()._build_filter("record.category == 'travel'")
+
+
+async def test_create_search_tool_returns_mapped_results() -> None:
+ collection = MockCollection()
+ collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]}
+ tool = create_vector_search_tool(
+ collection,
+ name="search_records",
+ approval_mode="always_require",
+ top=1,
+ result_mapper=lambda response: f"{response['record'].id}:{response['score']}",
+ )
+
+ result = await tool(query="first")
+
+ assert tool.name == "search_records"
+ assert tool.approval_mode == "always_require"
+ assert len(result) == 1
+ assert result[0].text == "one:0.9"
+
+
+async def test_create_search_tool_supports_declared_filter_parameters() -> None:
+ collection = MockCollection()
+ collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]}
+ tool = create_vector_search_tool(
+ collection,
+ parameters={
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "The search query."},
+ "category": {"type": "string", "description": "The category to match."},
+ "top": {
+ "type": "integer",
+ "description": "The maximum number of results.",
+ "maximum": 5,
+ },
+ "skip": {
+ "type": "integer",
+ "description": "The number of results to skip.",
+ "maximum": 10,
+ },
+ },
+ "required": ["query", "category"],
+ "additionalProperties": False,
+ },
+ )
+
+ await tool(query="first", category="travel", top=1, skip=2)
+
+ assert set(tool.parameters()["properties"]) == {"query", "category", "top", "skip"}
+ assert collection.last_search_filter == "record.category == 'travel'"
+ assert collection.last_search_top == 1
+ assert collection.last_search_skip == 2
+
+
+def test_create_search_tool_validates_custom_schema() -> None:
+ collection = MockCollection()
+
+ with pytest.raises(ValueError, match="required string"):
+ create_vector_search_tool(collection, parameters={"type": "object", "properties": {}})
+ with pytest.raises(ValueError, match="required string"):
+ create_vector_search_tool(
+ collection,
+ parameters={
+ "type": "object",
+ "properties": {"query": {"type": "integer"}},
+ "required": ["query"],
+ },
+ )
+ with pytest.raises(ValueError, match="declare an integer maximum"):
+ create_vector_search_tool(
+ collection,
+ parameters={
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "top": {"type": "integer"},
+ },
+ "required": ["query"],
+ },
+ )
+
+
+async def test_create_search_tool_enforces_paging_limits_and_result_cap() -> None:
+ collection = MockCollection()
+ collection.raw_search_results = [
+ {"record": {"record_id": str(index), "body": f"record {index}"}, "score": 0.9} for index in range(3)
+ ]
+ tool = create_vector_search_tool(
+ collection,
+ top=2,
+ parameters={
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "top": {"type": "integer", "maximum": 2},
+ "skip": {"type": "integer", "maximum": 4},
+ },
+ "required": ["query"],
+ },
+ )
+
+ results = await tool(query="records", top=2, skip=4)
+ assert len(results) == 2
+
+ with pytest.raises(ValueError, match="top must not exceed"):
+ await tool(query="records", top=3)
+ with pytest.raises(ValueError, match="skip must not exceed"):
+ await tool(query="records", skip=5)
+
+
+async def test_create_search_tool_supports_multimodal_results() -> None:
+ collection = MockCollection()
+ collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]}
+ tool = create_vector_search_tool(
+ collection,
+ top=1,
+ result_mapper=lambda response: [
+ Content.from_text(response["record"].text),
+ Content.from_uri("https://example.com/result.png", media_type="image/png"),
+ ],
+ )
+
+ result = await tool.invoke(arguments={"query": "first"})
+
+ assert [content.type for content in result] == ["text", "uri"]
+
+
+async def test_create_search_tool_uses_msgspec_for_default_result_mapping() -> None:
+ collection = MockCollection()
+ collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]}
+
+ result = await create_vector_search_tool(collection, top=1)(query="first")
+
+ assert result[0].text is not None
+ decoded = msgspec.json.decode(result[0].text)
+ assert set(create_vector_search_tool(collection).parameters()["properties"]) == {"query"}
+ assert decoded["record"]["id"] == "one"
+ assert decoded["score"] == 0.9
+
+
+async def test_create_search_tool_defers_unsupported_type_to_search() -> None:
+ class VectorOnlyCollection(MockCollection):
+ supported_search_types: ClassVar[set[SearchType]] = {"vector"}
+
+ tool = create_vector_search_tool(VectorOnlyCollection(), search_type="keyword_hybrid")
+ with pytest.raises(NotImplementedError, match="not supported"):
+ await tool(query="query")
+
+
+def test_search_protocol_and_tool_factory_only_require_search() -> None:
+ class SearchOnly:
+ async def search(
+ self,
+ values: Any,
+ *,
+ search_type: SearchType = "vector",
+ vector: Sequence[float | int] | None = None,
+ filter: Any = None,
+ top: int = 3,
+ skip: int = 0,
+ include_vectors: bool = False,
+ vector_property_name: str | None = None,
+ additional_property_name: str | None = None,
+ score_threshold: float | None = None,
+ operation_options: Mapping[str, Any] | None = None,
+ ) -> SearchResults[SearchResponse[Record]]:
+ return SearchResults([])
+
+ search = SearchOnly()
+ assert isinstance(cast(Any, search), SupportsVectorSearch)
+ assert create_vector_search_tool(cast(SupportsVectorSearch[Record], search)).name == "search"
+
+
+def test_collection_satisfies_vector_protocols() -> None:
+ collection = MockCollection()
+
+ assert isinstance(collection, SupportsVectorUpsert)
+ assert isinstance(collection, SupportsVectorSearch)
+
+
+async def test_vector_store_collection_lifecycle_helpers() -> None:
+ collection = MockCollection()
+ store = MockStore(collection)
+
+ assert not await store.collection_exists("records")
+ await collection.ensure_collection_exists()
+ assert await store.collection_exists("records")
+ await store.ensure_collection_deleted("records")
+ assert not await store.collection_exists("records")
+
+
+def test_search_response_holds_record_and_score() -> None:
+ record = Record("one", "hello")
+ response = SearchResponse(record=record, score=0.75)
+
+ assert response["record"] is record
+ assert response["score"] == 0.75
+
+
+def test_deserialization_rejects_non_mapping_store_records() -> None:
+ handler = VectorStoreRecordHandler(Record)
+
+ with pytest.raises(TypeError, match="must be mappings"):
+ handler.deserialize(object())
+
+
+def test_additional_field_and_definition_validation_paths() -> None:
+ with pytest.raises(ValueError, match="Unknown vector store field type"):
+ cast(Any, VectorStoreField)("unknown")
+ with pytest.raises(ValueError, match="must not be empty"):
+ VectorStoreCollectionDefinition([VectorStoreField("key")])
+ with pytest.raises(ValueError, match="storage names must be unique"):
+ VectorStoreCollectionDefinition([
+ VectorStoreField("key", name="id", storage_name="same"),
+ VectorStoreField("data", name="text", storage_name="same"),
+ ])
+
+ definition = cast(VectorStoreCollectionDefinition, vars(Record)["__vectorstoremodel_definition__"])
+ assert definition.try_get_vector_field("vector") is definition.vector_fields[0]
+ assert definition.try_get_vector_field("missing") is None
+
+
+async def test_default_codecs_cover_pydantic_plain_and_unsupported_models() -> None:
+ @vectorstoremodel
+ class PydanticRecord(BaseModel):
+ id: Annotated[str, VectorStoreField("key")]
+
+ @vectorstoremodel
+ class PlainRecord:
+ id: Annotated[str, VectorStoreField("key")]
+
+ def __init__(self, id: str) -> None:
+ self.id = id
+
+ @vectorstoremodel
+ class SlottedRecord:
+ __slots__ = ("id",)
+ id: Annotated[str, VectorStoreField("key")]
+
+ def __init__(self, id: str) -> None:
+ self.id = id
+
+ assert await VectorStoreRecordHandler(PydanticRecord).serialize(PydanticRecord(id="one")) == {"id": "one"}
+ assert await VectorStoreRecordHandler(PlainRecord).serialize(PlainRecord("one")) == {"id": "one"}
+ with pytest.raises(NotImplementedError, match="SlottedRecord"):
+ await VectorStoreRecordHandler(SlottedRecord).serialize(SlottedRecord("one"))
+
+
+def test_vectorstoremodel_rejects_unresolvable_or_missing_annotations() -> None:
+ class UnresolvableRecord:
+ __annotations__ = {"id": "MissingRecordType"}
+
+ class EmptyRecord:
+ pass
+
+ with pytest.raises(ValueError, match="Unable to resolve"):
+ vectorstoremodel(UnresolvableRecord)
+ with pytest.raises(ValueError, match="at least one annotated field"):
+ vectorstoremodel(EmptyRecord)
+
+
+def test_registration_is_idempotent_and_rejects_changed_codecs() -> None:
+ @dataclass
+ class RegisteredRecord:
+ id: str
+
+ definition = VectorStoreCollectionDefinition([VectorStoreField("key", name="id")])
+
+ def encoder(record: RegisteredRecord) -> Mapping[str, Any]:
+ return {"id": record.id}
+
+ def decoder(record: Mapping[str, Any]) -> RegisteredRecord:
+ return RegisteredRecord(cast(str, record["id"]))
+
+ register_vectorstoremodel(RegisteredRecord, definition=definition, encoder=encoder, decoder=decoder)
+ register_vectorstoremodel(RegisteredRecord, definition=definition, encoder=encoder, decoder=decoder)
+
+ with pytest.raises(ValueError, match="another encoder"):
+ register_vectorstoremodel(
+ RegisteredRecord,
+ definition=definition,
+ encoder=lambda record: {"id": record.id},
+ decoder=decoder,
+ )
+ with pytest.raises(ValueError, match="another decoder"):
+ register_vectorstoremodel(
+ RegisteredRecord,
+ definition=definition,
+ encoder=encoder,
+ decoder=lambda record: RegisteredRecord(cast(str, record["id"])),
+ )
+
+
+def test_record_handler_requires_registered_models_or_explicit_dict_definitions() -> None:
+ class UnregisteredRecord:
+ pass
+
+ with pytest.raises(ValueError, match="explicit"):
+ VectorStoreRecordHandler(dict)
+ with pytest.raises(ValueError, match="must be registered"):
+ VectorStoreRecordHandler(UnregisteredRecord)
+
+ other_definition = VectorStoreCollectionDefinition([VectorStoreField("key", name="other_id")])
+ with pytest.raises(ValueError, match="another definition"):
+ VectorStoreRecordHandler(Record, definition=other_definition)
+
+
+async def test_serialization_shape_and_embedding_failures() -> None:
+ definition = VectorStoreCollectionDefinition([
+ VectorStoreField("key", name="id", storage_name="record_id"),
+ VectorStoreField("data", name="text", storage_name="body"),
+ ])
+ dict_handler = VectorStoreRecordHandler(dict, definition=definition)
+ assert await dict_handler.serialize({"record_id": "one", "body": "hello"}) == {
+ "record_id": "one",
+ "body": "hello",
+ }
+ with pytest.raises(TypeError, match="must serialize to mappings"):
+ await dict_handler.serialize(cast(Any, 1))
+
+ collection = MockCollection(embedding_generator=MockEmbeddingClient())
+ with pytest.raises(ValueError, match="value is missing"):
+ await collection.serialize(Record("one", "hello"))
+
+ class EmptyEmbeddingClient(MockEmbeddingClient):
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[list[float]]:
+ return GeneratedEmbeddings()
+
+ with pytest.raises(IntegrationInvalidResponseException, match="returned 0 vectors"):
+ await MockCollection(embedding_generator=EmptyEmbeddingClient()).serialize(Record("one", "hello", "embed"))
+
+ assert dict_handler.deserialize(None) is None
+
+
+async def test_array_like_generated_embeddings_are_normalized() -> None:
+ class ArrayLike:
+ def tolist(self) -> list[float]:
+ return [0.1, 0.2]
+
+ class ArrayEmbeddingClient(MockEmbeddingClient):
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[Any]:
+ return GeneratedEmbeddings([Embedding(vector=ArrayLike()) for _ in values])
+
+ collection = MockCollection(embedding_generator=ArrayEmbeddingClient())
+ serialized = await collection.serialize(Record("one", "hello", "embed"))
+ assert serialized["vector"] == [0.1, 0.2]
+
+ results = await collection.search("query")
+ assert collection.last_search_vector == [0.1, 0.2]
+ assert [result async for result in results] == []
+
+
+async def test_collection_operation_error_boundaries_and_context_manager() -> None:
+ collection = MockCollection()
+ async with collection as entered:
+ assert entered is collection
+
+ collection.upsert_error = IntegrationException("known upsert failure")
+ with pytest.raises(IntegrationException, match="known upsert failure"):
+ await collection.upsert([Record("one", "hello")], generate_vectors=False)
+ collection.upsert_error = None
+ collection.upsert_keys = []
+ with pytest.raises(IntegrationInvalidResponseException, match="Expected 1 upserted keys"):
+ await collection.upsert([Record("one", "hello")], generate_vectors=False)
+
+ collection.get_error = RuntimeError("get failure")
+ with pytest.raises(IntegrationException, match="get failure"):
+ await collection.get(["one"])
+ collection.get_error = None
+ collection.delete_error = IntegrationException("known delete failure")
+ with pytest.raises(IntegrationException, match="known delete failure"):
+ await collection.delete(["one"])
+ collection.delete_error = RuntimeError("delete failure")
+ with pytest.raises(IntegrationException, match="delete failure"):
+ await collection.delete(["one"])
+
+ class FailingEmbeddingClient(MockEmbeddingClient):
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[list[float]]:
+ raise RuntimeError("embedding down")
+
+ with pytest.raises(IntegrationException, match="embedding down"):
+ await MockCollection(embedding_generator=FailingEmbeddingClient()).upsert([Record("one", "hello", "embed")])
+
+
+async def test_vector_store_context_and_missing_collection_delete() -> None:
+ collection = MockCollection()
+ store = MockStore(collection)
+
+ async with store as entered:
+ assert entered is store
+ await store.ensure_collection_deleted("missing")
+ assert not collection.created
+
+
+async def test_additional_search_validation_and_error_boundaries() -> None:
+ collection = MockCollection()
+ with pytest.raises(ValueError, match="Unknown search type"):
+ await collection.search("query", search_type=cast(Any, "unknown"))
+ with pytest.raises(ValueError, match="Keyword-hybrid"):
+ await cast(Any, collection.search)(search_type="keyword_hybrid", vector=[1.0, 0.0])
+ with pytest.raises(ValueError, match="was not found"):
+ await collection.search("query", vector_property_name="missing")
+
+ collection.search_error = IntegrationException("known search failure")
+ with pytest.raises(IntegrationException, match="known search failure"):
+ await collection.search("query")
+ collection.search_error = RuntimeError("search failure")
+ with pytest.raises(IntegrationException, match="search failure"):
+ await collection.search("query")
+
+
+async def test_search_embedding_and_result_conversion_failures() -> None:
+ class EmptyEmbeddingClient(MockEmbeddingClient):
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[list[float]]:
+ return GeneratedEmbeddings()
+
+ class StringEmbeddingClient(MockEmbeddingClient):
+ async def get_embeddings(
+ self,
+ values: Sequence[Any],
+ *,
+ options: EmbeddingGenerationOptions | None = None,
+ ) -> GeneratedEmbeddings[Any]:
+ return GeneratedEmbeddings([Embedding(vector="invalid")])
+
+ with pytest.raises(IntegrationInvalidResponseException, match="returned 0 vectors"):
+ await MockCollection(embedding_generator=EmptyEmbeddingClient()).search("query")
+ with pytest.raises(TypeError, match="unsupported vector type"):
+ await MockCollection(embedding_generator=StringEmbeddingClient()).search("query")
+
+ collection = MockCollection()
+ collection.raw_search_results = [{"record": None, "score": 0.9}]
+ results = await collection.search(vector=[1.0, 0.0])
+ assert [result async for result in results] == []
+
+ collection.raw_search_results = [{"record": [{"record_id": "one", "body": "hello"}], "score": 0.9}]
+ results = await collection.search(vector=[1.0, 0.0])
+ with pytest.raises(IntegrationInvalidResponseException, match="exactly one record"):
+ _ = [result async for result in results]
+
+ collection.raw_search_results = [object()]
+ results = await collection.search(vector=[1.0, 0.0])
+ with pytest.raises(IntegrationInvalidResponseException, match="result conversion failed"):
+ _ = [result async for result in results]
+
+ async def failing_results() -> AsyncIterable[Any]:
+ yield {"record": {"record_id": "one", "body": "hello"}, "score": 0.9}
+ raise RuntimeError("stream disconnected")
+
+ collection.raw_search_results = failing_results()
+ results = await collection.search(vector=[1.0, 0.0])
+ with pytest.raises(IntegrationException, match="iteration failed.*stream disconnected"):
+ _ = [result async for result in results]
+
+
+async def test_scoreless_results_remain_when_threshold_cannot_be_applied() -> None:
+ collection = MockCollection()
+ collection.raw_search_results = [{"record": {"record_id": "one", "body": "hello"}, "score": None}]
+
+ results = await collection.search(vector=[1.0, 0.0], score_threshold=0.5)
+
+ responses = [result async for result in results]
+ assert len(responses) == 1
+ assert responses[0]["score"] is None
+
+
+def test_filter_parser_and_default_mapper_edge_paths() -> None:
+ collection = MockCollection()
+ assert collection._build_filter(lambda record: record.id == "one") == "record.id == 'one'"
+ with pytest.raises(ValueError, match="Unable to parse"):
+ collection._build_filter("lambda record:")
+
+
+async def test_search_tool_filter_mapper_edge_paths() -> None:
+ collection = MockCollection()
+ parameters = {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "category": {"type": "string"},
+ },
+ "required": ["query", "category"],
+ }
+ tool = create_vector_search_tool(
+ collection,
+ parameters=parameters,
+ filter=["lambda record: record.id != 'ignored'"],
+ )
+ await tool(query="query", category="travel")
+ assert collection.last_search_filter == ["record.id != 'ignored'", "record.category == 'travel'"]
+
+ invalid_tool = create_vector_search_tool(
+ collection,
+ parameters={
+ "type": "object",
+ "properties": {"query": {"type": "string"}, "bad-name": {"type": "string"}},
+ "required": ["query"],
+ },
+ )
+ with pytest.raises(ValueError, match="cannot be mapped"):
+ await invalid_tool(query="query", **{"bad-name": "value"})
+ with pytest.raises(TypeError, match="'query'.*string"):
+ await cast(Any, create_vector_search_tool(collection))(query=1)
+
+
+async def test_runtime_operations_mark_vector_store_feature_usage() -> None:
+ collection = MockCollection()
+ store = MockStore(collection)
+
+ with patch("agent_framework._vectors.mark_feature_used") as mark_feature_used_mock:
+ await collection.serialize(Record("one", "hello"), generate_vectors=False)
+ mark_feature_used_mock.assert_called_with(FeatureIndex.CORE_VECTOR_STORES)
+
+ mark_feature_used_mock.reset_mock()
+ collection.deserialize({"record_id": "one", "body": "hello", "vector": None})
+ mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES)
+
+ mark_feature_used_mock.reset_mock()
+ await collection.upsert([Record("one", "hello")], generate_vectors=False)
+ mark_feature_used_mock.assert_any_call(FeatureIndex.CORE_VECTOR_STORES)
+
+ mark_feature_used_mock.reset_mock()
+ await collection.get(["one"])
+ mark_feature_used_mock.assert_any_call(FeatureIndex.CORE_VECTOR_STORES)
+
+ mark_feature_used_mock.reset_mock()
+ await collection.delete(["one"])
+ mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES)
+
+ mark_feature_used_mock.reset_mock()
+ await store.collection_exists("records")
+ mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES)
+
+ mark_feature_used_mock.reset_mock()
+ await collection.search(vector=[1.0, 0.0])
+ mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES)
diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py
index ccb1e9425bf..2cc2ed2ce6e 100644
--- a/python/packages/core/tests/workflow/test_agent_executor.py
+++ b/python/packages/core/tests/workflow/test_agent_executor.py
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
+import pickle
+
from collections.abc import AsyncIterable, Awaitable
from typing import Any, Literal, overload
@@ -336,6 +338,9 @@ class _NonCopyableRaw:
def __deepcopy__(self, memo: dict) -> Any:
raise TypeError("Cannot deepcopy this object")
+ def __reduce__(self) -> Any:
+ raise TypeError("Cannot pickle this object")
+
class _AgentWithRawRepr(BaseAgent):
"""Agent that returns responses with a non-copyable raw_representation."""
@@ -387,6 +392,20 @@ async def test_agent_executor_workflow_with_non_copyable_raw_representation() ->
assert agent_responses[0].raw_representation is raw
+def test_serialization_mixin_omits_non_pickleable_raw_representation() -> None:
+ """Pickling framework objects should not include runtime-only raw representations."""
+ raw = _NonCopyableRaw()
+ response = AgentResponse(
+ messages=[Message("assistant", [Content.from_text(text="reply", raw_representation=raw)])],
+ raw_representation=raw,
+ )
+
+ restored = pickle.loads(pickle.dumps(response))
+
+ assert restored.raw_representation is None
+ assert restored.messages[0].contents[0].raw_representation is None
+
+
# ---------------------------------------------------------------------------
# Context mode tests
# ---------------------------------------------------------------------------
diff --git a/python/packages/declarative/tests/test_default_mcp_tool_handler.py b/python/packages/declarative/tests/test_default_mcp_tool_handler.py
index 1523f2261f0..a8329dea278 100644
--- a/python/packages/declarative/tests/test_default_mcp_tool_handler.py
+++ b/python/packages/declarative/tests/test_default_mcp_tool_handler.py
@@ -2,12 +2,15 @@
"""Tests for ``DefaultMCPToolHandler``.
-These tests exercise the real handler against a fake ``MCPStreamableHTTPTool``
+Most tests exercise the real handler against a fake ``MCPStreamableHTTPTool``
(no real MCP server, no real network) to cover the parts of the handler not
exercisable through the executor stub: cache hit/miss/eviction, concurrent
connect via in-flight futures, header isolation across cache keys,
string-result normalisation, ``load_prompts=False`` verification, and
owned-vs-caller httpx close semantics.
+
+The shared-client regression also exercises the real MCP SDK transport against
+an in-process HTTPX mock server.
"""
from __future__ import annotations
@@ -147,6 +150,83 @@ def _invocation(
)
+@pytest.mark.parametrize("cache_max_size", [1, 2])
+async def test_shared_client_isolates_cached_authentication_and_cleans_up_hooks(cache_max_size: int) -> None:
+ sessions: dict[str, str] = {}
+ calls: list[tuple[str, str]] = []
+ writes: dict[str, list[str]] = {"token-a": [], "token-b": []}
+
+ async def caller_hook(request: httpx.Request) -> None:
+ request.headers["X-Caller"] = "preserved"
+
+ async def handle(request: httpx.Request) -> httpx.Response:
+ assert request.headers["X-Caller"] == "preserved"
+ principal = request.headers.get("Authorization", "")
+ if principal not in writes:
+ return httpx.Response(401)
+ if request.method == "GET":
+ return httpx.Response(405)
+ if request.method == "DELETE":
+ return httpx.Response(200)
+ body = json.loads(request.content)
+ headers: dict[str, str] = {}
+ result: dict[str, Any] = {}
+ if body.get("method") == "initialize":
+ session_id = f"session-{len(sessions)}"
+ sessions[session_id] = principal
+ headers["mcp-session-id"] = session_id
+ result = {
+ "protocolVersion": body["params"]["protocolVersion"],
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "auth-test", "version": "1"},
+ }
+ elif body.get("method") == "tools/list":
+ result = {
+ "tools": [
+ {
+ "name": "search",
+ "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}},
+ }
+ ]
+ }
+ elif body.get("method") == "tools/call":
+ calls.append((request.headers["mcp-session-id"], principal))
+ if marker := body["params"].get("arguments", {}).get("marker"):
+ writes[principal].append(marker)
+ result = {"content": [{"type": "text", "text": principal}]}
+ if "id" not in body:
+ return httpx.Response(202)
+ return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result})
+
+ async with httpx.AsyncClient(
+ transport=httpx.MockTransport(handle), event_hooks={"request": [caller_hook]}
+ ) as client:
+
+ async def client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient:
+ return client
+
+ async with DefaultMCPToolHandler(client_provider=client_provider, cache_max_size=cache_max_size) as handler:
+ outputs: list[str | None] = []
+ for index, principal in enumerate(("token-a", "token-b", "token-a")):
+ result = await handler.invoke_tool(
+ _invocation(
+ headers={"Authorization": principal},
+ arguments={"marker": "a-only"} if index == 2 else {},
+ )
+ )
+ assert not result.is_error
+ outputs.append(result.outputs[0].text)
+ assert outputs == ["token-a", "token-b", "token-a"]
+ assert len(client.event_hooks["request"]) == 1 + cache_max_size
+
+ assert [principal for _, principal in calls] == ["token-a", "token-b", "token-a"]
+ assert all(sessions[session_id] == principal for session_id, principal in calls)
+ assert len(sessions) == (3 if cache_max_size == 1 else 2)
+ assert writes == {"token-a": ["a-only"], "token-b": []}
+ assert client.event_hooks["request"] == [caller_hook]
+ assert not client.is_closed
+
+
# ---------- Construction ---------------------------------------------------
diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json
index e9a910f54a6..d420f28df63 100644
--- a/python/packages/devui/frontend/package-lock.json
+++ b/python/packages/devui/frontend/package-lock.json
@@ -552,41 +552,41 @@
"license": "MIT"
},
"node_modules/@humanfs/core": {
- "version": "0.19.1",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
- "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
- "version": "0.16.6",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
- "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@humanfs/core": "^0.19.1",
- "@humanwhocodes/retry": "^0.3.0"
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
- "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
- "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
- "node": ">=18.18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
+ "node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
@@ -2696,9 +2696,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.37",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
- "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2733,9 +2733,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.2",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
- "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -2753,11 +2753,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.10.12",
- "caniuse-lite": "^1.0.30001782",
- "electron-to-chromium": "^1.5.328",
- "node-releases": "^2.0.36",
- "update-browserslist-db": "^1.2.3"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -2777,9 +2777,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001799",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
- "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -3043,9 +3043,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.372",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
- "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==",
+ "version": "1.5.420",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
+ "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"dev": true,
"license": "ISC"
},
@@ -4067,9 +4067,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.47",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
- "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
+ "version": "2.0.54",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+ "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4650,9 +4650,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock
index aee9b5be61f..ccc07de1fce 100644
--- a/python/packages/devui/frontend/yarn.lock
+++ b/python/packages/devui/frontend/yarn.lock
@@ -322,30 +322,33 @@
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
-"@humanfs/core@^0.19.1":
- version "0.19.1"
- resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"
- integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==
+"@humanfs/core@^0.19.2":
+ version "0.19.2"
+ resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
+ integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
+ dependencies:
+ "@humanfs/types" "^0.15.0"
"@humanfs/node@^0.16.6":
- version "0.16.6"
- resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz"
- integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==
+ version "0.16.8"
+ resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
+ integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
dependencies:
- "@humanfs/core" "^0.19.1"
- "@humanwhocodes/retry" "^0.3.0"
+ "@humanfs/core" "^0.19.2"
+ "@humanfs/types" "^0.15.0"
+ "@humanwhocodes/retry" "^0.4.0"
+
+"@humanfs/types@^0.15.0":
+ version "0.15.0"
+ resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
+ integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
"@humanwhocodes/module-importer@^1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz"
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
-"@humanwhocodes/retry@^0.3.0":
- version "0.3.1"
- resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz"
- integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==
-
-"@humanwhocodes/retry@^0.4.2":
+"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
version "0.4.3"
resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
@@ -1249,6 +1252,11 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+baseline-browser-mapping@^2.11.12:
+ version "2.11.20"
+ resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
+ integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
+
brace-expansion@^1.1.7:
version "1.1.16"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.16.tgz#723d3a30c0558c225abc9fc479a73e14e26c3c2f"
@@ -1272,24 +1280,25 @@ braces@^3.0.3:
fill-range "^7.1.1"
browserslist@^4.24.0:
- version "4.25.3"
- resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz"
- integrity sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==
+ version "4.28.8"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0"
+ integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==
dependencies:
- caniuse-lite "^1.0.30001735"
- electron-to-chromium "^1.5.204"
- node-releases "^2.0.19"
- update-browserslist-db "^1.1.3"
+ baseline-browser-mapping "^2.11.12"
+ caniuse-lite "^1.0.30001809"
+ electron-to-chromium "^1.5.402"
+ node-releases "^2.0.53"
+ update-browserslist-db "^1.3.0"
callsites@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
-caniuse-lite@^1.0.30001735:
- version "1.0.30001736"
- resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001736.tgz"
- integrity sha512-ImpN5gLEY8gWeqfLUyEF4b7mYWcYoR2Si1VhnrbM4JizRFmfGaAQ12PhNykq6nvI4XvKLrsp8Xde74D5phJOSw==
+caniuse-lite@^1.0.30001809:
+ version "1.0.30001810"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2"
+ integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==
chalk@^4.0.0:
version "4.1.2"
@@ -1436,10 +1445,10 @@ detect-node-es@^1.1.0:
resolved "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
-electron-to-chromium@^1.5.204:
- version "1.5.208"
- resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz"
- integrity sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg==
+electron-to-chromium@^1.5.402:
+ version "1.5.420"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44"
+ integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==
enhanced-resolve@5.21.6:
version "5.21.6"
@@ -1942,10 +1951,10 @@ next-themes@^0.4.6:
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz"
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
-node-releases@^2.0.19:
- version "2.0.19"
- resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz"
- integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
+node-releases@^2.0.53:
+ version "2.0.54"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe"
+ integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==
optionator@^0.9.3:
version "0.9.4"
@@ -2230,10 +2239,10 @@ undici-types@~7.10.0:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
-update-browserslist-db@^1.1.3:
- version "1.1.3"
- resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz"
- integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==
+update-browserslist-db@^1.3.0:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836"
+ integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==
dependencies:
escalade "^3.2.0"
picocolors "^1.1.1"
diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml
index 1ee46d9a39f..173f378c044 100644
--- a/python/packages/devui/pyproject.toml
+++ b/python/packages/devui/pyproject.toml
@@ -26,7 +26,7 @@ classifiers = [
"agent-framework-core>=1.17.0,<2",
"openai>=2.45.0,<4",
"opentelemetry-sdk>=1.39.0,<2",
- "fastapi>=0.115.0,<0.138.1",
+ "fastapi>=0.115.0,<0.142.0",
"uvicorn[standard]>=0.30.0,<1"
]
diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md
index 74e37bbfb04..1bc0051fec0 100644
--- a/python/packages/foundry/README.md
+++ b/python/packages/foundry/README.md
@@ -2,6 +2,32 @@
This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers.
+## Evaluations
+
+`FoundryEvals` implements the provider-neutral `Evaluator` protocol with
+Microsoft Foundry's built-in and generated evaluators. Core owns `EvalItem`,
+local evaluation, and the `evaluate_agent()` / `evaluate_workflow()`
+orchestration functions; this package owns the Foundry Evals data mappings,
+wire serialization, submission, polling, and result parsing.
+
+Use `evaluate_agent()` for the common run-and-evaluate path:
+
+```python
+from agent_framework import evaluate_agent
+from agent_framework.foundry import FoundryEvals
+
+results = await evaluate_agent(
+ agent=agent,
+ queries=["What's the weather in Seattle?"],
+ evaluators=FoundryEvals(),
+)
+```
+
+For manual control, construct public `EvalItem` instances and pass them to
+`FoundryEvals.evaluate()`. The Foundry wire format is private to this package.
+`evaluate_traces()` and `evaluate_foundry_target()` provide Foundry-specific
+entry points for existing traces, response IDs, and registered targets.
+
## Concurrent reuse
A `FoundryChatClient` instance can be shared by concurrent asynchronous calls on the same event loop. Streaming,
diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py
index 25fd3bd817a..37dfb4a7a4a 100644
--- a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py
+++ b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py
@@ -3,8 +3,7 @@
"""Microsoft Foundry Evals integration for Microsoft Agent Framework.
Provides ``FoundryEvals``, an ``Evaluator`` implementation backed by Azure AI
-Foundry's built-in evaluators. See docs/decisions/0018-foundry-evals-integration.md
-for the design rationale.
+Foundry's built-in evaluators.
Example:
@@ -27,13 +26,14 @@
from __future__ import annotations
import asyncio
+import contextlib
+import json
import logging
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from agent_framework._evaluation import (
- AgentEvalConverter,
ConversationSplit,
ConversationSplitter,
EvalItem,
@@ -44,6 +44,7 @@
)
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework._telemetry import mark_feature_used
+from agent_framework._types import Message
from openai import AsyncOpenAI
from ._chat_client import FoundryChatClient
@@ -217,6 +218,69 @@ def _resolve_evaluator(name: str) -> str:
# ---------------------------------------------------------------------------
+def _convert_message(message: Message) -> list[dict[str, Any]]:
+ """Convert one Agent Framework message to the Foundry Evals wire format."""
+ content_items: list[dict[str, Any]] = []
+ tool_results: list[dict[str, Any]] = []
+
+ for content in message.contents or []:
+ if content.type == "text" and content.text:
+ content_items.append({"type": "text", "text": content.text})
+ elif content.type in ("data", "uri") and content.uri:
+ image: dict[str, Any] = {
+ "type": "input_image",
+ "image_url": content.uri,
+ }
+ if content.media_type:
+ image["detail"] = "auto"
+ content_items.append(image)
+ elif content.type == "function_call":
+ arguments = content.arguments
+ if isinstance(arguments, str):
+ try:
+ arguments = json.loads(arguments)
+ except (json.JSONDecodeError, TypeError):
+ arguments = {"_raw_arguments": "[unparseable]"}
+ content_items.append({
+ "type": "tool_call",
+ "tool_call_id": content.call_id or "",
+ "name": content.name or "",
+ "arguments": arguments if arguments is not None else {},
+ })
+ elif content.type == "function_result":
+ result = content.result
+ if isinstance(result, str):
+ with contextlib.suppress(json.JSONDecodeError, TypeError):
+ result = json.loads(result)
+ tool_results.append({
+ "call_id": content.call_id or "",
+ "result": result,
+ })
+
+ if tool_results:
+ return [
+ {
+ "role": "tool",
+ "tool_call_id": tool_result["call_id"],
+ "content": [{"type": "tool_result", "tool_result": tool_result["result"]}],
+ }
+ for tool_result in tool_results
+ ]
+ if content_items:
+ return [{"role": message.role, "content": content_items}]
+ return [
+ {
+ "role": message.role,
+ "content": [{"type": "text", "text": ""}],
+ }
+ ]
+
+
+def _convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
+ """Convert Agent Framework messages to the Foundry Evals wire format."""
+ return [converted for message in messages for converted in _convert_message(message)]
+
+
def _build_testing_criteria(
evaluators: Sequence[str | GeneratedEvaluatorRef],
model: str,
@@ -882,7 +946,7 @@ async def evaluate(
evaluators and filters tool evaluators for items without tool definitions.
Args:
- items: Eval data items from ``AgentEvalConverter.to_eval_item()``.
+ items: Provider-neutral evaluation data items.
eval_name: Display name for the evaluation run.
Returns:
@@ -919,8 +983,8 @@ async def _evaluate_via_dataset(
d: dict[str, Any] = {
"query": query_text,
"response": response_text,
- "query_messages": AgentEvalConverter.convert_messages(query_msgs),
- "response_messages": AgentEvalConverter.convert_messages(response_msgs),
+ "query_messages": _convert_messages(query_msgs),
+ "response_messages": _convert_messages(response_msgs),
}
if item.tools:
d["tool_definitions"] = [
diff --git a/python/packages/foundry/tests/test_foundry_evals.py b/python/packages/foundry/tests/test_foundry_evals.py
index f2b015e04c4..6832f27b7a2 100644
--- a/python/packages/foundry/tests/test_foundry_evals.py
+++ b/python/packages/foundry/tests/test_foundry_evals.py
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
-"""Tests for the AgentEvalConverter, FoundryEvals, and eval helper functions."""
+"""Tests for Foundry Evals wire conversion and evaluation helpers."""
from __future__ import annotations
@@ -12,8 +12,6 @@
import pytest
from agent_framework import AgentExecutorResponse, AgentResponse, Content, FunctionTool, Message, WorkflowEvent
from agent_framework._evaluation import (
- AgentEvalConverter,
- ConversationSplit,
EvalItem,
EvalNotPassedError,
EvalResults,
@@ -33,6 +31,8 @@
FoundryEvals,
_build_item_schema,
_build_testing_criteria,
+ _convert_message,
+ _convert_messages,
_extract_per_evaluator,
_extract_result_counts,
_extract_rubric_scores,
@@ -109,25 +109,25 @@ def test_unknown_raises(self) -> None:
# ---------------------------------------------------------------------------
-# AgentEvalConverter.convert_message
+# Foundry Evals message conversion
# ---------------------------------------------------------------------------
class TestConvertMessage:
def test_user_text_message(self) -> None:
msg = Message("user", ["Hello, world!"])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert result[0] == {"role": "user", "content": [{"type": "text", "text": "Hello, world!"}]}
def test_system_message(self) -> None:
msg = Message("system", ["You are helpful."])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert result[0] == {"role": "system", "content": [{"type": "text", "text": "You are helpful."}]}
def test_assistant_text_message(self) -> None:
msg = Message("assistant", ["Here is the answer."])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "assistant"
assert result[0]["content"] == [{"type": "text", "text": "Here is the answer."}]
@@ -144,7 +144,7 @@ def test_assistant_with_tool_call(self) -> None:
),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "assistant"
tc = result[0]["content"][0]
@@ -164,7 +164,7 @@ def test_assistant_with_zero_argument_tool_call(self) -> None:
),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
tc = result[0]["content"][0]
assert tc["type"] == "tool_call"
assert "arguments" in tc
@@ -182,7 +182,7 @@ def test_assistant_text_and_tool_call(self) -> None:
),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert result[0]["content"][0] == {"type": "text", "text": "Let me check that."}
tc = result[0]["content"][1]
@@ -199,7 +199,7 @@ def test_tool_result_message(self) -> None:
),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "tool"
assert result[0]["tool_call_id"] == "call_1"
@@ -213,7 +213,7 @@ def test_multiple_tool_results(self) -> None:
Content.from_function_result(call_id="call_2", result="r2"),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 2
assert result[0]["tool_call_id"] == "call_1"
assert result[1]["tool_call_id"] == "call_2"
@@ -228,21 +228,21 @@ def test_non_string_result_kept_as_object(self) -> None:
),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
tr = result[0]["content"][0]
assert tr["type"] == "tool_result"
assert tr["tool_result"] == {"temp": 72, "unit": "F"}
def test_empty_message(self) -> None:
msg = Message("user", [])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert result[0] == {"role": "user", "content": [{"type": "text", "text": ""}]}
def test_user_image_from_data(self) -> None:
"""Image created via Content.from_data() emits input_image."""
img = Content.from_data(data=b"\x89PNG\r\n\x1a\n", media_type="image/png")
msg = Message("user", [img])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "user"
part = result[0]["content"][0]
@@ -254,7 +254,7 @@ def test_user_image_from_uri(self) -> None:
"""Image created via Content.from_uri() with an external URL."""
img = Content.from_uri("https://example.com/photo.jpg", media_type="image/jpeg")
msg = Message("user", [img])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
part = result[0]["content"][0]
assert part["type"] == "input_image"
@@ -265,7 +265,7 @@ def test_user_image_uri_without_media_type(self) -> None:
"""URI content without media_type still emits input_image (no detail key)."""
img = Content("uri", uri="https://example.com/pic.png")
msg = Message("user", [img])
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
part = result[0]["content"][0]
assert part["type"] == "input_image"
assert part["image_url"] == "https://example.com/pic.png"
@@ -280,7 +280,7 @@ def test_mixed_text_and_image(self) -> None:
Content.from_uri("https://example.com/cat.jpg", media_type="image/jpeg"),
],
)
- result = AgentEvalConverter.convert_message(msg)
+ result = _convert_message(msg)
assert len(result) == 1
assert len(result[0]["content"]) == 2
assert result[0]["content"][0] == {"type": "text", "text": "What's in this image?"}
@@ -289,7 +289,7 @@ def test_mixed_text_and_image(self) -> None:
# ---------------------------------------------------------------------------
-# AgentEvalConverter.convert_messages
+# Foundry Evals conversation conversion
# ---------------------------------------------------------------------------
@@ -304,7 +304,7 @@ def test_full_conversation(self) -> None:
Message("tool", [Content.from_function_result(call_id="c1", result="Sunny")]),
Message("assistant", ["It's sunny in Seattle!"]),
]
- result = AgentEvalConverter.convert_messages(messages)
+ result = _convert_messages(messages)
assert len(result) == 4
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
@@ -327,7 +327,7 @@ def test_multimodal_conversation_preserves_images(self) -> None:
),
Message("assistant", ["This is a photo of a sunset over the ocean."]),
]
- result = AgentEvalConverter.convert_messages(messages)
+ result = _convert_messages(messages)
assert len(result) == 2
# User message has text + image
user_content = result[0]["content"]
@@ -339,412 +339,6 @@ def test_multimodal_conversation_preserves_images(self) -> None:
assert result[1]["content"] == [{"type": "text", "text": "This is a photo of a sunset over the ocean."}]
-# ---------------------------------------------------------------------------
-# AgentEvalConverter.extract_tools
-# ---------------------------------------------------------------------------
-
-
-class TestExtractTools:
- def test_extracts_function_tools(self) -> None:
- tool = FunctionTool(
- name="get_weather",
- description="Get weather for a location",
- func=lambda location: f"Sunny in {location}",
- )
- agent = MagicMock()
- agent.default_options = {"tools": [tool]}
-
- result = AgentEvalConverter.extract_tools(agent)
- assert len(result) == 1
- assert result[0]["name"] == "get_weather"
- assert result[0]["description"] == "Get weather for a location"
- assert "parameters" in result[0]
-
- def test_skips_non_function_tools(self) -> None:
- agent = MagicMock()
- agent.default_options = {"tools": [{"type": "web_search"}, "some_string"]}
-
- result = AgentEvalConverter.extract_tools(agent)
- assert len(result) == 0
-
- def test_no_tools(self) -> None:
- agent = MagicMock()
- agent.default_options = {}
- assert AgentEvalConverter.extract_tools(agent) == []
-
- def test_no_default_options(self) -> None:
- agent = MagicMock(spec=[]) # No attributes
- assert AgentEvalConverter.extract_tools(agent) == []
-
-
-# ---------------------------------------------------------------------------
-# AgentEvalConverter.to_eval_item (now returns EvalItem)
-# ---------------------------------------------------------------------------
-
-
-class TestToEvalItem:
- def test_string_query(self) -> None:
- response = AgentResponse(messages=[Message("assistant", ["The weather is sunny."])])
- item = AgentEvalConverter.to_eval_item(query="What's the weather?", response=response)
-
- assert isinstance(item, EvalItem)
- assert item.query == "What's the weather?"
- assert item.response == "The weather is sunny."
- assert len(item.conversation) == 2
- assert item.conversation[0].role == "user"
- assert item.conversation[1].role == "assistant"
-
- def test_message_query(self) -> None:
- input_msgs = [
- Message("system", ["Be helpful."]),
- Message("user", ["Hello"]),
- ]
- response = AgentResponse(messages=[Message("assistant", ["Hi there!"])])
- item = AgentEvalConverter.to_eval_item(query=input_msgs, response=response)
-
- assert item.query == "Hello" # Only user messages
- assert len(item.conversation) == 3 # system + user + assistant
-
- def test_with_context(self) -> None:
- response = AgentResponse(messages=[Message("assistant", ["Answer."])])
- item = AgentEvalConverter.to_eval_item(
- query="Question?",
- response=response,
- context="Some reference document.",
- )
- assert item.context == "Some reference document."
-
- def test_with_explicit_tools(self) -> None:
- tool = FunctionTool(
- name="search",
- description="Search the web",
- func=lambda q: f"Results for {q}",
- )
- response = AgentResponse(messages=[Message("assistant", ["Found it."])])
- item = AgentEvalConverter.to_eval_item(
- query="Find info",
- response=response,
- tools=[tool],
- )
- assert item.tools is not None
- assert len(item.tools) == 1
- assert item.tools[0].name == "search"
-
- def test_with_agent_tools(self) -> None:
- tool = FunctionTool(name="calc", description="Calculate", func=lambda x: str(x))
- agent = MagicMock()
- agent.default_options = {"tools": [tool]}
-
- response = AgentResponse(messages=[Message("assistant", ["42"])])
- item = AgentEvalConverter.to_eval_item(
- query="What is 6*7?",
- response=response,
- agent=agent,
- )
- assert item.tools is not None
- assert item.tools[0].name == "calc"
-
- def test_explicit_tools_override_agent(self) -> None:
- agent_tool = FunctionTool(name="agent_tool", description="from agent", func=lambda: "")
- explicit_tool = FunctionTool(name="explicit_tool", description="explicit", func=lambda: "")
-
- agent = MagicMock()
- agent.default_options = {"tools": [agent_tool]}
-
- response = AgentResponse(messages=[Message("assistant", ["Done"])])
- item = AgentEvalConverter.to_eval_item(
- query="Test",
- response=response,
- agent=agent,
- tools=[explicit_tool],
- )
- assert item.tools is not None
- assert len(item.tools) == 1
- assert item.tools[0].name == "explicit_tool"
-
- def test_split_messages_format(self) -> None:
- """split_messages() should split conversation at last user message."""
- response = AgentResponse(messages=[Message("assistant", ["Answer"])])
- item = AgentEvalConverter.to_eval_item(
- query="Q",
- response=response,
- tools=[FunctionTool(name="t", description="d", func=lambda: "")],
- )
- query_msgs, response_msgs = item.split_messages()
- # Single-turn: query has just the user msg, response has the assistant msg
- assert len(query_msgs) == 1
- assert query_msgs[0].role == "user"
- assert len(response_msgs) == 1
- assert response_msgs[0].role == "assistant"
- # Tools preserved on item
- assert item.tools is not None
- assert len(item.tools) == 1
- assert item.tools[0].name == "t"
-
- def test_split_messages_multiturn_preserves_interleaving(self) -> None:
- """Multi-turn split_messages() splits at last user message, preserving interleaving."""
- conversation = [
- Message("user", ["What's the weather?"]),
- Message("assistant", ["It's sunny in Seattle."]),
- Message("user", ["And tomorrow?"]),
- Message("assistant", [Content(type="function_call", name="get_forecast")]),
- Message("tool", [Content(type="function_result", result="Rain expected")]),
- Message("assistant", ["Rain is expected tomorrow."]),
- ]
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages()
- # query_messages: everything up to and including the last user message
- assert len(query_msgs) == 3 # user, assistant, user
- assert query_msgs[0].role == "user"
- assert query_msgs[1].role == "assistant" # interleaved!
- assert query_msgs[2].role == "user"
- # response_messages: everything after the last user message
- assert len(response_msgs) == 3 # assistant(tool_call), tool, assistant
- assert response_msgs[0].role == "assistant"
- assert response_msgs[1].role == "tool"
- assert response_msgs[2].role == "assistant"
-
- def test_split_messages_full_split(self) -> None:
- """ConversationSplit.FULL splits after the first user message."""
- conversation = [
- Message("user", ["What's the weather?"]),
- Message("assistant", ["It's 62°F in Seattle."]),
- Message("user", ["And tomorrow?"]),
- Message("assistant", ["Rain is expected tomorrow."]),
- ]
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.FULL))
- # query_messages: just the first user message
- assert len(query_msgs) == 1
- assert query_msgs[0].role == "user"
- assert query_msgs[0].text == "What's the weather?"
- # response_messages: everything after the first user message
- assert len(response_msgs) == 3
- assert response_msgs[0].role == "assistant"
- assert response_msgs[1].role == "user"
- assert response_msgs[2].role == "assistant"
-
- def test_split_messages_full_split_with_system(self) -> None:
- """FULL split includes system messages before the first user message in query."""
- conversation = [
- Message("system", ["You are a weather assistant."]),
- Message("user", ["What's the weather?"]),
- Message("assistant", ["It's sunny."]),
- ]
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.FULL))
- # query includes system + first user
- assert len(query_msgs) == 2
- assert query_msgs[0].role == "system"
- assert query_msgs[1].role == "user"
- assert len(response_msgs) == 1
-
- def test_split_messages_full_split_with_tools(self) -> None:
- """FULL split puts all tool interactions in response_messages."""
- conversation = [
- Message("user", ["What's the weather?"]),
- Message("assistant", [Content(type="function_call", name="get_weather")]),
- Message("tool", [Content(type="function_result", result="62°F")]),
- Message("assistant", ["It's 62°F."]),
- Message("user", ["Thanks!"]),
- Message("assistant", ["You're welcome!"]),
- ]
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.FULL))
- assert len(query_msgs) == 1
- assert len(response_msgs) == 5
-
- def test_split_messages_last_turn_is_default(self) -> None:
- """Default split_messages() uses LAST_TURN split."""
- conversation = [
- Message("user", ["Hello"]),
- Message("assistant", ["Hi there"]),
- Message("user", ["Bye"]),
- Message("assistant", ["Goodbye"]),
- ]
- item = EvalItem(conversation=conversation)
- q_default, r_default = item.split_messages()
- q_explicit, r_explicit = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN))
- assert [m.role for m in q_default] == [m.role for m in q_explicit]
- assert [m.text for m in q_default] == [m.text for m in q_explicit]
- assert [m.role for m in r_default] == [m.role for m in r_explicit]
- assert [m.text for m in r_default] == [m.text for m in r_explicit]
-
- def test_per_turn_items_simple(self) -> None:
- """per_turn_items produces one EvalItem per user message."""
- conversation = [
- Message("user", ["What's the weather?"]),
- Message("assistant", ["It's 62°F."]),
- Message("user", ["And tomorrow?"]),
- Message("assistant", ["Rain expected."]),
- ]
- items = EvalItem.per_turn_items(conversation)
- assert len(items) == 2
-
- # Turn 1
- assert items[0].query == "What's the weather?"
- assert items[0].response == "It's 62°F."
- assert len(items[0].conversation) == 2
-
- # Turn 2 â includes cumulative context; query joins all user texts in query split
- assert items[1].query == "What's the weather? And tomorrow?"
- assert items[1].response == "Rain expected."
- assert len(items[1].conversation) == 4
-
- def test_per_turn_items_with_tools(self) -> None:
- """per_turn_items handles tool calls within a turn."""
- conversation = [
- Message("user", ["Check weather"]),
- Message("assistant", [Content(type="function_call", name="get_weather")]),
- Message("tool", [Content(type="function_result", result="sunny")]),
- Message("assistant", ["It's sunny."]),
- Message("user", ["Thanks"]),
- Message("assistant", ["You're welcome!"]),
- ]
- tool_objs = [_make_tool("get_weather")]
- items = EvalItem.per_turn_items(conversation, tools=cast(Any, tool_objs))
- assert len(items) == 2
-
- # Turn 1: response includes tool_call, tool_result, and final assistant
- assert items[0].response == "It's sunny."
- assert items[0].tools == tool_objs
- assert len(items[0].conversation) == 4 # user, assistant(tool), tool, assistant
-
- # Turn 2
- assert items[1].response == "You're welcome!"
- assert len(items[1].conversation) == 6 # full conversation
-
- def test_per_turn_items_empty(self) -> None:
- """per_turn_items returns empty list when no user messages."""
- items = EvalItem.per_turn_items([Message("assistant", ["Hello"])])
- assert items == []
-
- def test_per_turn_items_single_turn(self) -> None:
- """per_turn_items with single turn produces one item."""
- conversation = [
- Message("user", ["Hi"]),
- Message("assistant", ["Hello!"]),
- ]
- items = EvalItem.per_turn_items(conversation)
- assert len(items) == 1
- assert items[0].query == "Hi"
- assert items[0].response == "Hello!"
-
- def test_custom_splitter_callable(self) -> None:
- """Custom callable splitter is used by split_messages()."""
- conversation = [
- Message("user", ["Remember my name is Alice"]),
- Message("assistant", ["Got it, Alice!"]),
- Message("user", ["What's the capital of France?"]),
- Message("assistant", [Content(type="function_call", name="retrieve_memory", call_id="m1")]),
- Message("tool", [Content(type="function_result", call_id="m1", result="User name: Alice")]),
- Message("assistant", ["The capital of France is Paris, Alice!"]),
- ]
-
- def split_before_memory(conversation):
- """Split just before the memory retrieval tool call."""
- for i, msg in enumerate(conversation):
- for c in msg.contents:
- if c.name == "retrieve_memory":
- return conversation[:i], conversation[i:]
- return EvalItem._split_last_turn_static(conversation)
-
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages(split=split_before_memory)
-
- # split_before_memory finds "retrieve_memory" at conv[3] (assistant tool_call msg)
- # query = conv[:3] = [user, assistant, user]
- # response = conv[3:] = [assistant(tool_call), tool, assistant]
- assert len(query_msgs) == 3
- assert query_msgs[-1].role == "user"
- assert len(response_msgs) == 3
- assert response_msgs[0].role == "assistant" # the tool_call msg
-
- def test_custom_splitter_with_fallback(self) -> None:
- """Custom splitter falls back to _split_last_turn_static when pattern not found."""
- conversation = [
- Message("user", ["Hello"]),
- Message("assistant", ["Hi there!"]),
- ]
-
- def split_before_memory(conversation):
- for i, msg in enumerate(conversation):
- for c in msg.contents:
- if c.name == "retrieve_memory":
- return conversation[:i], conversation[i:]
- return EvalItem._split_last_turn_static(conversation)
-
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages(split=split_before_memory)
- # Falls back to last-turn split
- assert len(query_msgs) == 1
- assert query_msgs[0].role == "user"
- assert len(response_msgs) == 1
- assert response_msgs[0].role == "assistant"
-
- def test_custom_splitter_lambda(self) -> None:
- """A lambda works as a custom splitter."""
- conversation = [
- Message("user", ["A"]),
- Message("assistant", ["B"]),
- Message("user", ["C"]),
- Message("assistant", ["D"]),
- ]
- # Split at index 2 (arbitrary)
- item = EvalItem(conversation=conversation)
- query_msgs, response_msgs = item.split_messages(split=lambda conversation: (conversation[:2], conversation[2:]))
- assert len(query_msgs) == 2
- assert len(response_msgs) == 2
-
- def test_split_strategy_on_item_used_by_split_messages(self) -> None:
- """split_strategy field on EvalItem is used as default by split_messages()."""
- conversation = [
- Message("user", ["First"]),
- Message("assistant", ["Response 1"]),
- Message("user", ["Second"]),
- Message("assistant", ["Response 2"]),
- ]
- item = EvalItem(
- conversation=conversation,
- split_strategy=cast(Any, ConversationSplit.FULL),
- )
- # split_messages() with no split arg should use item.split_strategy
- query_msgs, response_msgs = item.split_messages()
- assert len(query_msgs) == 1 # FULL: just first user msg
- assert query_msgs[0].text == "First"
- assert len(response_msgs) == 3
-
- def test_explicit_split_overrides_item_split_strategy(self) -> None:
- """Explicit split= arg to split_messages() overrides item.split_strategy."""
- conversation = [
- Message("user", ["First"]),
- Message("assistant", ["Response 1"]),
- Message("user", ["Second"]),
- Message("assistant", ["Response 2"]),
- ]
- item = EvalItem(
- conversation=conversation,
- split_strategy=cast(Any, ConversationSplit.FULL),
- )
- # Explicit split= should override split_strategy
- query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN))
- assert len(query_msgs) == 3 # LAST_TURN: up to last user
- assert query_msgs[-1].text == "Second"
- assert len(response_msgs) == 1
-
- def test_no_split_defaults_to_last_turn(self) -> None:
- """When neither split= nor split_strategy is set, defaults to LAST_TURN."""
- conversation = [
- Message("user", ["Hello"]),
- Message("assistant", ["Hi"]),
- ]
- item = EvalItem(conversation=conversation)
- assert item.split_strategy is None
- query_msgs, response_msgs = item.split_messages()
- assert len(query_msgs) == 1
- assert query_msgs[0].role == "user"
-
-
# ---------------------------------------------------------------------------
# _build_testing_criteria
# ---------------------------------------------------------------------------
diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py
index 28d54de8a4a..17b2feafdb6 100644
--- a/python/packages/gemini/agent_framework_gemini/_chat_client.py
+++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py
@@ -33,11 +33,18 @@
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage]
-from agent_framework.exceptions import ContentError
+from agent_framework.exceptions import (
+ AgentFrameworkException,
+ ChatClientException,
+ ChatClientInvalidAuthException,
+ ChatClientInvalidRequestException,
+ ContentError,
+)
from agent_framework.observability import ChatTelemetryLayer
from google import genai
from google.auth.credentials import Credentials
from google.genai import types
+from google.genai.errors import APIError as GenAIAPIError
from pydantic import BaseModel
from ._feature_usage import FeatureIndex
@@ -71,6 +78,24 @@
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
+def _wrap_gemini_error(ex: Exception) -> ChatClientException:
+ """Translate a raw google-genai failure into the framework's ChatClientException hierarchy.
+
+ google-genai ``APIError`` instances are classified by HTTP status (401/403 -> auth, other
+ 4xx -> invalid request), matching the Mistral client. Anything else - transport errors,
+ Vertex credential-refresh failures, unexpected SDK exceptions - is still wrapped as a
+ generic ``ChatClientException`` so callers catching that base type never see a raw
+ provider exception leak through.
+ """
+ if isinstance(ex, GenAIAPIError):
+ code = getattr(ex, "code", None)
+ if code in (401, 403):
+ return ChatClientInvalidAuthException(f"Gemini authentication failed: {ex}", inner_exception=ex)
+ if isinstance(code, int) and 400 <= code < 500:
+ return ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex)
+ return ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=ex)
+
+
# region Options & Settings
@@ -559,12 +584,17 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]:
Callable[..., Awaitable[AsyncIterable[types.GenerateContentResponse]]],
cast(Any, self._genai_client.aio.models).generate_content_stream,
)
- async for chunk in await generate_content_stream(
- model=model,
- contents=contents,
- config=config,
- ):
- yield self._process_chunk(chunk)
+ try:
+ async for chunk in await generate_content_stream(
+ model=model,
+ contents=contents,
+ config=config,
+ ):
+ yield self._process_chunk(chunk)
+ except AgentFrameworkException:
+ raise
+ except Exception as ex:
+ raise _wrap_gemini_error(ex) from ex
return self._build_response_stream(_stream(), response_format=options.get("response_format"))
@@ -572,7 +602,16 @@ async def _get_response() -> ChatResponse:
validated = await self._validate_options(options)
model, contents, config = self._prepare_request(messages, validated)
mark_feature_used(FeatureIndex.GEMINI)
- raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type]
+ generate_content = cast(
+ Callable[..., Awaitable[types.GenerateContentResponse]],
+ cast(Any, self._genai_client.aio.models).generate_content,
+ )
+ try:
+ raw = await generate_content(model=model, contents=contents, config=config)
+ except AgentFrameworkException:
+ raise
+ except Exception as ex:
+ raise _wrap_gemini_error(ex) from ex
return self._process_generate_response(raw, response_format=validated.get("response_format"))
return _get_response()
diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py
index 1b5d87b749c..74b1687e1d3 100644
--- a/python/packages/gemini/tests/test_gemini_client.py
+++ b/python/packages/gemini/tests/test_gemini_client.py
@@ -12,6 +12,12 @@
import pytest
from agent_framework import Agent, Content, FunctionTool, Message
+from agent_framework.exceptions import (
+ ChatClientException,
+ ChatClientInvalidAuthException,
+ ChatClientInvalidRequestException,
+)
+from google.genai import errors as genai_errors
from google.genai import types
from pydantic import BaseModel
from typing_extensions import NotRequired, TypedDict
@@ -378,6 +384,60 @@ async def test_get_response_returns_text() -> None:
assert response.messages[0].text == "Hello!"
+@pytest.mark.parametrize(
+ ("sdk_exception", "expected_exception"),
+ [
+ (genai_errors.ClientError(401, {"error": {"message": "invalid api key"}}), ChatClientInvalidAuthException),
+ (genai_errors.ClientError(403, {"error": {"message": "permission denied"}}), ChatClientInvalidAuthException),
+ (genai_errors.ClientError(400, {"error": {"message": "bad request"}}), ChatClientInvalidRequestException),
+ (genai_errors.ServerError(500, {"error": {"message": "server error"}}), ChatClientException),
+ # Not a google-genai APIError at all (transport failure, credential refresh, ...):
+ # must still be wrapped so ``except ChatClientException`` callers never see it raw.
+ (RuntimeError("connection reset"), ChatClientException),
+ ],
+)
+async def test_get_response_wraps_sdk_errors(
+ sdk_exception: Exception, expected_exception: type[Exception]
+) -> None:
+ """Non-streaming get_response must translate raw google-genai SDK errors into the
+ framework's ChatClientException hierarchy, matching every other provider
+ (OpenAI, Anthropic, Mistral, Ollama, Bedrock)."""
+ client, mock = _make_gemini_client()
+ mock.aio.models.generate_content = AsyncMock(side_effect=sdk_exception)
+
+ with pytest.raises(expected_exception, match="Gemini"):
+ await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])])
+
+
+async def test_get_response_streaming_wraps_sdk_errors() -> None:
+ """Streaming get_response must translate raw google-genai SDK errors into the
+ framework's ChatClientException hierarchy too, both when the call itself fails
+ and when the failure happens partway through iterating the stream."""
+ # 1. Failure raised by the generate_content_stream call itself.
+ client, mock = _make_gemini_client()
+ mock.aio.models.generate_content_stream = AsyncMock(
+ side_effect=genai_errors.ClientError(401, {"error": {"message": "invalid api key"}})
+ )
+ with pytest.raises(ChatClientInvalidAuthException, match="Gemini"):
+ async for _ in client.get_response(
+ messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True
+ ):
+ pass
+
+ # 2. Failure raised mid-stream, after at least one chunk has been yielded.
+ async def _raise_after_first_chunk(**_: Any):
+ yield _make_response([_make_part(text="partial")])
+ raise genai_errors.ClientError(403, {"error": {"message": "permission denied"}})
+
+ client, mock = _make_gemini_client()
+ mock.aio.models.generate_content_stream = AsyncMock(return_value=_raise_after_first_chunk())
+ with pytest.raises(ChatClientInvalidAuthException, match="Gemini"):
+ async for _ in client.get_response(
+ messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True
+ ):
+ pass
+
+
async def test_get_response_model_from_response() -> None:
"""Populates ChatResponse.model from the model_version field in the API response."""
client, mock = _make_gemini_client()
diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
index 2f7b963cb75..811d329891f 100644
--- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
+++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py
@@ -30,6 +30,7 @@
add_usage_details,
normalize_messages,
)
+from agent_framework._mcp import MCPTool
from agent_framework._settings import load_settings
from agent_framework._telemetry import mark_feature_used
from agent_framework._tools import FunctionTool, ToolTypes
@@ -77,6 +78,7 @@
Attachment,
BlobAttachment,
MCPServerConfig,
+ PermissionInvocation,
PermissionRequestResult,
PreToolUseHandler,
PreToolUseHookOutput,
@@ -110,14 +112,24 @@
DEFAULT_TIMEOUT_SECONDS: float = 60.0
"""Default timeout in seconds for Copilot requests."""
+_PermissionHandlerContext = Any
+"""Compatibility context accepted by permission handlers across SDK versions."""
+
PermissionHandlerType = Callable[
- [PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
+ [PermissionRequest, _PermissionHandlerContext],
+ "PermissionRequestResult | Awaitable[PermissionRequestResult]",
]
"""Type for permission request handlers. Supports both sync and async callbacks."""
-AsyncPermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], "Awaitable[PermissionRequestResult]"]
+AsyncPermissionHandlerType = Callable[
+ [PermissionRequest, _PermissionHandlerContext], "Awaitable[PermissionRequestResult]"
+]
"""Type for permission request handlers that are always asynchronous."""
+_SdkAsyncPermissionHandlerType = Callable[
+ [PermissionRequest, PermissionInvocation], "Awaitable[PermissionRequestResult]"
+]
+
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
"""Deprecated approval callback for ``FunctionTool`` instances declared with
@@ -168,10 +180,26 @@ async def _resolve_function_approval(
logger = logging.getLogger("agent_framework.github_copilot")
+_MCP_TOOL_MESSAGE = (
+ "MCP server '{name}' cannot be passed to GitHubCopilotAgent as a tool: the Copilot SDK "
+ "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its "
+ "framework behavior. Configure the server natively instead, for example "
+ "default_options={{'mcp_servers': {{'{name}': {{'type': 'stdio', 'command': 'python', "
+ "'args': ['server.py'], 'tools': ['*']}}}}}}, or use a ChatAgent, where the framework owns "
+ "the connection."
+)
+
+
+def _reject_mcp_tools(tools: Sequence[Any]) -> None:
+ """Refuse MCP servers handed in as tools, from whichever option carried them."""
+ for tool in tools:
+ if isinstance(tool, MCPTool):
+ raise TypeError(_MCP_TOOL_MESSAGE.format(name=tool.name))
+
def _deny_all_permissions(
_request: PermissionRequest,
- _invocation: dict[str, str],
+ _invocation: _PermissionHandlerContext,
) -> PermissionRequestResult:
"""Default permission handler that denies all requests."""
return PermissionDecisionUserNotAvailable()
@@ -322,7 +350,7 @@ def _normalize_permission_decision(
return PermissionDecisionApproveForSession(approval=approval)
-def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> AsyncPermissionHandlerType:
+def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> _SdkAsyncPermissionHandlerType:
"""Wrap a permission handler so its decisions are normalized before reaching the SDK.
Exceptions raised by ``handler`` deliberately propagate: the SDK already catches them
@@ -335,8 +363,10 @@ def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> Asy
An async handler delegating to ``handler`` and normalizing its result.
"""
- async def normalized_handler(request: PermissionRequest, invocation: dict[str, str]) -> PermissionRequestResult:
- result = handler(request, invocation)
+ async def normalized_handler(
+ request: PermissionRequest, invocation: PermissionInvocation
+ ) -> PermissionRequestResult:
+ result = handler(request, cast(PermissionInvocation, dict(invocation)))
if inspect.isawaitable(result):
result = await result
return _normalize_permission_decision(result, request)
@@ -641,6 +671,7 @@ def __init__(
)
self._tools = normalize_tools(tools)
+ _reject_mcp_tools(self._tools)
self._permission_handler = on_permission_request
self._on_pre_tool_use: PreToolUseHandler | None = on_pre_tool_use
self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
@@ -1459,7 +1490,10 @@ def _build_session_kwargs(
# Merge agent-level tools with any caller-supplied tools (from default_options
# or per-run options, the latter winning) and convert to SDK tools.
- all_tools = list(self._tools or []) + list(kwargs.get("tools") or [])
+ # Normalize the option-supplied tools the way the constructor does: it converts callables
+ # and flattens tool-collection wrappers, which can otherwise hide an MCPTool.
+ all_tools = normalize_tools(list(self._tools or []) + list(kwargs.get("tools") or []))
+ _reject_mcp_tools(all_tools)
kwargs["tools"] = self._prepare_tools(all_tools) if all_tools else None
kwargs["streaming"] = streaming
@@ -1468,7 +1502,10 @@ def _build_session_kwargs(
if not kwargs.get("model"):
kwargs["model"] = self._settings.get("model") or None
kwargs["on_permission_request"] = _with_normalized_permission_decisions(
- opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
+ cast(
+ PermissionHandlerType,
+ opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions,
+ )
)
kwargs["hooks"] = self._build_session_hooks(all_tools, kwargs)
diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml
index ef660ec3032..c9205a397d8 100644
--- a/python/packages/github_copilot/pyproject.toml
+++ b/python/packages/github_copilot/pyproject.toml
@@ -23,7 +23,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.15.0,<2",
- "github-copilot-sdk==1.0.2; python_version >= '3.11'",
+ "github-copilot-sdk==1.0.11; python_version >= '3.11'",
]
[tool.uv]
diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py
index 6e17db2f733..37412e42048 100644
--- a/python/packages/github_copilot/tests/test_github_copilot_agent.py
+++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py
@@ -9,6 +9,7 @@
import unittest.mock
from collections.abc import Sequence
from datetime import datetime, timezone
+from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@@ -24,11 +25,12 @@
Content,
ContextProvider,
HistoryProvider,
+ MCPStdioTool,
Message,
tool,
)
from agent_framework.exceptions import AgentException
-from copilot.session import PermissionHandler, PreToolUseHookInput
+from copilot.session import PermissionHandler, PermissionInvocation, PreToolUseHookInput
from copilot.session_events import (
AssistantUsageData,
Data,
@@ -1329,7 +1331,7 @@ async def test_resume_session_includes_tools_and_permissions(
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
- def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+ def my_handler(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
return PermissionDecisionApproveOnce()
def my_tool(arg: str) -> str:
@@ -1940,6 +1942,48 @@ async def test_arbitrary_option_forwarded_verbatim(
assert config["reasoning_effort"] == "high"
assert config["context_tier"] == "large"
+ async def test_mcp_tool_is_rejected_with_the_native_configuration(
+ self,
+ mock_client: MagicMock,
+ ) -> None:
+ """An MCP server cannot keep its framework behavior here, so it is refused, not dropped."""
+ with pytest.raises(TypeError, match="mcp_servers"):
+ GitHubCopilotAgent(client=mock_client, tools=[MCPStdioTool(name="weather", command="python")])
+
+ async def test_mcp_tool_in_a_tuple_is_rejected(
+ self,
+ mock_client: MagicMock,
+ ) -> None:
+ """``tools`` takes any sequence, so a tuple must not slip past the refusal."""
+ with pytest.raises(TypeError, match="mcp_servers"):
+ GitHubCopilotAgent(client=mock_client, tools=(MCPStdioTool(name="weather", command="python"),))
+
+ async def test_mcp_tool_from_default_options_is_rejected(
+ self,
+ mock_client: MagicMock,
+ ) -> None:
+ """Tools reach the SDK from the options too, so the refusal cannot live in the constructor alone."""
+ agent = GitHubCopilotAgent(
+ client=mock_client,
+ default_options=cast(Any, {"tools": [MCPStdioTool(name="weather", command="python")]}),
+ )
+ await agent.start()
+
+ with pytest.raises(AgentException, match="mcp_servers"):
+ await agent._get_or_create_session(AgentSession()) # type: ignore[reportPrivateUsage]
+
+ async def test_mcp_tool_inside_a_tool_collection_from_options_is_rejected(
+ self,
+ mock_client: MagicMock,
+ ) -> None:
+ """Option-supplied tools are normalized too, so a wrapper cannot hide an MCP server."""
+ toolbox = SimpleNamespace(tools=[MCPStdioTool(name="weather", command="python")])
+ agent = GitHubCopilotAgent(client=mock_client, default_options=cast(Any, {"tools": [toolbox]}))
+ await agent.start()
+
+ with pytest.raises(AgentException, match="mcp_servers"):
+ await agent._get_or_create_session(AgentSession()) # type: ignore[reportPrivateUsage]
+
async def test_tools_from_default_options_are_honored(
self,
mock_client: MagicMock,
@@ -2603,7 +2647,7 @@ def test_permission_handler_set_when_provided(self) -> None:
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
- def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+ def approve_shell(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
if request.kind == "shell":
return PermissionDecisionApproveOnce()
return PermissionDecisionDeniedInteractivelyByUser()
@@ -2621,7 +2665,7 @@ async def test_session_config_includes_permission_handler(
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
- def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+ def approve_shell_read(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
if request.kind in ("shell", "read"):
return PermissionDecisionApproveOnce()
return PermissionDecisionDeniedInteractivelyByUser()
@@ -2943,6 +2987,24 @@ async def async_handler(_request: Any, _invocation: Any) -> Any:
assert isinstance(result, PermissionDecisionApproveForSession)
assert isinstance(result.approval, PermissionDecisionApproveForSessionApprovalCommands)
+ async def test_legacy_dict_permission_handlers_are_supported(self) -> None:
+ """The wrapper continues to support handlers typed for the legacy dictionary context."""
+ from copilot.generated.rpc import PermissionDecisionApproveOnce
+ from copilot.session_events import PermissionRequest
+
+ received_context: dict[str, str] = {}
+
+ def legacy_handler(request: PermissionRequest, context: dict[str, str]) -> Any:
+ received_context.update(context)
+ return PermissionDecisionApproveOnce()
+
+ from agent_framework_github_copilot._agent import _with_normalized_permission_decisions
+
+ handler = _with_normalized_permission_decisions(legacy_handler) # type: ignore[arg-type]
+ await handler(shell_request(["ls"]), {"session_id": "test-session"})
+
+ assert received_context == {"session_id": "test-session"}
+
async def test_handler_exceptions_propagate(self) -> None:
"""Handler failures must keep reaching the SDK, which denies the request."""
from agent_framework_github_copilot._agent import _with_normalized_permission_decisions
diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml
index 9f47ef04835..c51ffcae8a4 100644
--- a/python/packages/hosting-responses/pyproject.toml
+++ b/python/packages/hosting-responses/pyproject.toml
@@ -30,7 +30,7 @@ dependencies = [
[dependency-groups]
test = [
- "fastapi>=0.115.0,<0.138.1",
+ "fastapi>=0.115.0,<0.142.0",
"httpx>=0.28.1",
]
diff --git a/python/packages/lab/lightning/tests/test_lightning.py b/python/packages/lab/lightning/tests/test_lightning.py
index 8f1602b5660..85a829ffe06 100644
--- a/python/packages/lab/lightning/tests/test_lightning.py
+++ b/python/packages/lab/lightning/tests/test_lightning.py
@@ -126,7 +126,9 @@ async def test_observability(workflow_two_agents: Workflow):
| |
[chat gpt-4o] [chat gpt-4o]
"""
- pytest.importorskip("agentlightning")
+ # agentlightning imports optional proxy dependencies that may fail independently.
+ # Keep this optional observability test skippable when any such import is broken.
+ pytest.importorskip("agentlightning", exc_type=ImportError)
from agent_framework_lab_lightning import AgentFrameworkTracer
from agentlightning.adapter import TracerTraceToTriplet
diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml
index 1bdb8114b8d..53f885c22e5 100644
--- a/python/packages/lab/pyproject.toml
+++ b/python/packages/lab/pyproject.toml
@@ -57,8 +57,8 @@ math = [
[dependency-groups]
dev = [
- "uv==0.12.5",
- "ruff==0.16.3",
+ "uv==0.12.6",
+ "ruff==0.16.4",
"pytest==9.1.1",
"mypy==2.3.1",
"pyright==1.1.411",
diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py
index 8fda0acf34c..d6c24a078f2 100644
--- a/python/packages/purview/agent_framework_purview/_processor.py
+++ b/python/packages/purview/agent_framework_purview/_processor.py
@@ -230,6 +230,7 @@ async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> Proce
if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse):
return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key)
+ pc_request.process_inline = True
task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py
index e147e8666ab..9d7388a416b 100644
--- a/python/packages/purview/tests/purview/test_processor.py
+++ b/python/packages/purview/tests/purview/test_processor.py
@@ -283,6 +283,7 @@ async def test_process_with_scopes_calls_client_methods(
# On cache miss, ProcessContent runs in the foreground and the response is returned.
assert response.id == "response-123"
mock_client.process_content.assert_called_once()
+ assert mock_client.process_content.call_args.args[0].process_inline is True
# Protection scopes are refreshed in a background task.
await asyncio.gather(*list(processor._background_tasks))
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 3346db7a8fb..f9b168f6470 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -28,9 +28,9 @@ dependencies = [
[dependency-groups]
dev = [
- "uv==0.12.5",
+ "uv==0.12.6",
"flit==4.0.2",
- "ruff==0.16.3",
+ "ruff==0.16.4",
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
"pytest-cov==7.1.0",
@@ -40,8 +40,8 @@ dev = [
"mypy==2.3.1",
"pyright==1.1.411",
"pyrefly==1.2.0",
- "ty==0.0.72",
- "zuban==0.9.1",
+ "ty==0.0.75",
+ "zuban==0.9.2",
"opentelemetry-sdk",
#tasks
"poethepoet==0.48.0",
diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md
index e3fce52794b..83013a0e896 100644
--- a/python/samples/02-agents/mcp/README.md
+++ b/python/samples/02-agents/mcp/README.md
@@ -17,6 +17,60 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to
| **Progressive Disclosure** | [`mcp_progressive_disclosure.py`](mcp_progressive_disclosure.py) | Demonstrates `use_progressive_disclosure`, `always_load`, `allowed_tools`, and prefixed `list_mcp_tools` / `load_tool` / `unload_tool` names. `load_tool` and `unload_tool` can accept one tool name or multiple names. Self-spawns a stdio MCP child server |
| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default |
+## Anonymous web search and fetch
+
+Use `MCPStreamableHTTPTool` with [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) to search the public web and extract page content. This example calls the tools directly, so it needs neither a Parallel API key nor a model provider account. Free access is rate limited.
+
+Install the client dependencies in a Python 3.10+ environment:
+
+```bash
+pip install agent-framework-core "mcp>=1.24,<2"
+```
+
+Save this as `parallel_search.py` and run `python parallel_search.py`:
+
+```python
+import asyncio
+from uuid import uuid4
+
+from agent_framework import MCPStreamableHTTPTool
+
+
+async def main() -> None:
+ session_id = str(uuid4()) # Reuse for related search and fetch calls.
+ async with MCPStreamableHTTPTool(
+ name="parallel-search",
+ url="https://search.parallel.ai/mcp",
+ load_prompts=False,
+ request_timeout=30,
+ # Use the text payload once; Parallel also returns it as structured content.
+ parse_tool_results=lambda result: "\n".join(c.text for c in result.content if c.type == "text"),
+ ) as mcp:
+ print("Tools:", [tool.name for tool in mcp.functions])
+ search_result = await mcp.call_tool(
+ "web_search",
+ objective="Find Microsoft Agent Framework MCP documentation",
+ search_queries=["Microsoft Agent Framework MCP tools"],
+ session_id=session_id,
+ )
+ fetch_result = await mcp.call_tool(
+ "web_fetch",
+ urls=["https://github.com/microsoft/agent-framework"],
+ objective="Describe the framework's MCP support",
+ session_id=session_id,
+ )
+ for result in (search_result, fetch_result):
+ print(result)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+The output lists `web_search` and `web_fetch`, followed by their results, including source URLs and excerpts. Queries, requested URLs, objectives, and the session identifier are sent to Parallel when the calls run. The context manager closes the connection afterward.
+
+To make these tools available to an existing agent, pass the MCP tool as `tools` when constructing `Agent`. The agent can then choose to invoke them during a run; remove that tool to disable access. This example does not change any configured providers or defaults.
+
## Prerequisites
Most samples in this folder use OpenAI:
diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md
index 201a717e784..822006b17dd 100644
--- a/python/samples/02-agents/observability/README.md
+++ b/python/samples/02-agents/observability/README.md
@@ -155,6 +155,23 @@ os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = ""
enable_sensitive_telemetry()
```
+Or with [MLflow](https://mlflow.org/docs/latest/genai/tracing/integrations/listing/microsoft-agent-framework/), which ingests traces over OTLP/HTTP at `/v1/traces` and routes them to an experiment via a header. MLflow accepts traces only, so pass a span exporter rather than a base `OTEL_EXPORTER_OTLP_ENDPOINT` (which would also aim log and metric exporters at endpoints MLflow does not serve):
+
+```python
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
+from agent_framework.observability import configure_otel_providers, enable_sensitive_telemetry
+
+# Start a tracking server first, e.g. `mlflow server --backend-store-uri sqlite:///mlflow.db --port 5000`
+exporter = OTLPSpanExporter(
+ endpoint="http://localhost:5000/v1/traces",
+ headers={"x-mlflow-experiment-id": ""},
+)
+configure_otel_providers(exporters=[exporter])
+
+# Optional: opt in to capturing sensitive data
+enable_sensitive_telemetry()
+```
+
**4. Manual setup**
For full control, set up providers and exporters yourself. See [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a complete example that sends traces, logs, and metrics to the console. The `create_resource()` helper in `agent_framework.observability` can build a resource with the appropriate service name and version from environment variables (or sensible defaults), although the sample does not use it.
diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py
index 7f363add9a4..6fac5d3eb82 100644
--- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py
+++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py
@@ -15,11 +15,11 @@
from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
-from copilot.session import PermissionHandler, PermissionRequestResult
+from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult
from copilot.session_events import PermissionRequest
-async def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+async def prompt_permission(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
response = (await asyncio.to_thread(input, "Approve? (y/n): ")).strip().lower()
diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py
index 0502be7ea18..a191fab6281 100644
--- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py
+++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py
@@ -35,6 +35,7 @@
from copilot.generated.rpc import PermissionDecisionReject
from copilot.session import (
PermissionHandler,
+ PermissionInvocation,
PermissionRequestResult,
PreToolUseHookInput,
PreToolUseHookOutput,
@@ -63,13 +64,13 @@ def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Fr
)
-def approve_all_requests(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+def approve_all_requests(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
"""Permission handler that approves every request, including the gated tool."""
print(f"\n [Permission requested: {request.kind}] -> approved")
return PermissionHandler.approve_all(request, context)
-def deny_all_requests(request: PermissionRequest, _context: dict[str, str]) -> PermissionRequestResult:
+def deny_all_requests(request: PermissionRequest, _context: PermissionInvocation) -> PermissionRequestResult:
"""Permission handler that denies every request."""
print(f"\n [Permission requested: {request.kind}] -> denied")
return PermissionDecisionReject(feedback="Denied by the operator's policy.")
diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py
index 4e375e181a4..60d6377cd98 100644
--- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py
+++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py
@@ -20,11 +20,11 @@
import asyncio
from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions
-from copilot.session import PermissionHandler, PermissionRequestResult
+from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult
from copilot.session_events import PermissionRequest
-def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+def approve_and_log(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
"""Permission handler that auto-approves and logs each permission kind."""
print(f" [Permission: {request.kind}]", flush=True)
return PermissionHandler.approve_all(request, context)
diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py
index 0cd6ba3728a..50a84917d6b 100644
--- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py
+++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py
@@ -15,11 +15,11 @@
from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
-from copilot.session import PermissionHandler, PermissionRequestResult
+from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult
from copilot.session_events import PermissionRequest
-def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+def approve_and_log(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
"""Permission handler that approves only shell commands and logs them."""
if request.kind == "shell":
print(f"\n [Permission: {request.kind}]", flush=True)
diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py
index eb3edc5296b..1d57a2b0765 100644
--- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py
+++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py
@@ -15,11 +15,11 @@
from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
-from copilot.session import PermissionHandler, PermissionRequestResult
+from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult
from copilot.session_events import PermissionRequest
-def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
+def approve_and_log(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult:
"""Permission handler that approves only URL requests and logs them."""
if request.kind == "url":
print(f"\n [Permission: {request.kind}]", flush=True)
diff --git a/python/samples/02-agents/vector_stores/README.md b/python/samples/02-agents/vector_stores/README.md
new file mode 100644
index 00000000000..92227f0b1f0
--- /dev/null
+++ b/python/samples/02-agents/vector_stores/README.md
@@ -0,0 +1,56 @@
+# Vector stores
+
+Vector stores accept multiple model styles so applications can keep the data
+representation that already fits their validation, memory, and interoperability
+needs. When you own a model, annotate a dataclass, Pydantic model, msgspec
+struct, or plain class. When another team or package owns it, register an
+explicit definition and codecs. Dictionaries use a collection-specific
+definition; DataFrames and other containers can convert to row dictionaries
+before calling the batch API.
+
+No database or credentials are needed for these examples.
+
+| File | Demonstrates |
+|------|--------------|
+| [`vector_store_models.py`](vector_store_models.py) | Choosing among owned models, third-party model registration, and loose dictionary definitions. |
+| [`optimized_data_formats.py`](optimized_data_formats.py) | Keeping NumPy vector fields and adapting pandas DataFrames to the batch record API. |
+
+The first section shows the two equivalent custom-codec registration forms.
+`@vectorstoremodel` derives the definition from annotations and registers it;
+`register_vectorstoremodel` accepts an externally constructed definition.
+Both produce the same internal model registration.
+
+The sample order is informed by a small benchmark on Apple Silicon with
+CPython 3.13. Each benchmark model had the same `id`, `text`, and `vector`
+fields. Results are medians of seven warmed runs:
+
+| Model style | 3-element vector | 1,566-element vector |
+|-------------|-----------------:|---------------------:|
+| Custom codecs | 2.60 Îŧs | 7.01 Îŧs |
+| Dictionary | 2.39 Îŧs | 6.60 Îŧs |
+| Plain class | 3.46 Îŧs | 12.26 Îŧs |
+| msgspec `Struct` | 3.03 Îŧs | 16.61 Îŧs |
+| Dataclass | 3.17 Îŧs | 16.81 Îŧs |
+| Pydantic | 5.52 Îŧs | 37.32 Îŧs |
+
+These results measure only the framework's internal record conversion path.
+They do not include database SDK conversion, network I/O,
+embedding generation, validation complexity, nested fields, alternate vector
+representations, or memory allocation. Custom codecs are especially favorable
+here because the benchmark codec returns the existing vector reference rather
+than copying it. The middle ordering also changes with vector size, so treat
+these timings as illustrative data, not a recommendation or performance
+guarantee.
+
+Array-like vector values, including NumPy arrays, are serialized through their
+`tolist()` method without making NumPy a core dependency. If a model must
+restore a NumPy array instead of a Python list, pass a custom `decoder` to
+`@vectorstoremodel` or `register_vectorstoremodel` and call `numpy.array` or
+`numpy.asarray` there.
+
+Run the sample from the `python` directory:
+
+```bash
+uv run samples/02-agents/vector_stores/vector_store_models.py
+uv run samples/02-agents/vector_stores/optimized_data_formats.py
+```
diff --git a/python/samples/02-agents/vector_stores/optimized_data_formats.py b/python/samples/02-agents/vector_stores/optimized_data_formats.py
new file mode 100644
index 00000000000..a80693d5e5b
--- /dev/null
+++ b/python/samples/02-agents/vector_stores/optimized_data_formats.py
@@ -0,0 +1,121 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "agent-framework-core",
+# "numpy>=2,<3",
+# "pandas>=2,<4",
+# ]
+#
+# [tool.uv.sources]
+# agent-framework-core = { path = "../../../packages/core" }
+# ///
+
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+# Run with: uv run samples/02-agents/vector_stores/optimized_data_formats.py
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Annotated, Any, cast
+
+import numpy as np
+import pandas as pd # pyright: ignore[reportMissingImports]
+from agent_framework import (
+ VectorStoreCollectionDefinition,
+ VectorStoreField,
+ vectorstoremodel,
+)
+from numpy.typing import NDArray
+
+"""This sample demonstrates optimized vector data formats.
+
+When optimized formats already fit the application, Agent Framework should not
+force conversion at the model boundary. NumPy arrays reduce the in-memory
+footprint of large vectors, while pandas DataFrames can preserve an existing
+tabular data pipeline.
+
+NumPy vectors are encoded through ``tolist()`` without making NumPy a core
+dependency. A model decoder restores the array after retrieval. DataFrames are
+converted to ordinary row dictionaries before using the vector store batch API
+and reconstructed after retrieval. Agent Framework does not need
+container-specific behavior or a pandas dependency.
+
+These formats are choices, not requirements. A plain class or other application
+model may be simpler and entirely appropriate. For all standard model and
+third-party registration options, see
+[vector_store_models.py](vector_store_models.py).
+"""
+
+DIMENSIONS = 1566
+
+
+# 1. Use a NumPy array as a vector field.
+def decode_numpy_record(record: Mapping[str, Any]) -> NumpyRecord:
+ """Restore a NumPy vector after storage returned an ordinary list."""
+ return NumpyRecord(
+ record_id=cast(str, record["record_id"]),
+ vector=np.asarray(record["vector"], dtype=np.float32),
+ )
+
+
+@vectorstoremodel(collection_name="numpy-records", decoder=decode_numpy_record)
+@dataclass
+class NumpyRecord:
+ record_id: Annotated[str, VectorStoreField("key")]
+ vector: Annotated[
+ NDArray[np.float32],
+ VectorStoreField("vector", dimensions=DIMENSIONS, type_="float"),
+ ]
+
+
+# 2. Convert a pandas DataFrame to and from ordinary row dictionaries.
+dataframe_definition = VectorStoreCollectionDefinition(
+ [
+ VectorStoreField("key", name="id"),
+ VectorStoreField("data", name="text", is_full_text_indexed=True),
+ VectorStoreField("vector", name="vector", dimensions=3),
+ ],
+ collection_name="dataframe-records",
+)
+
+
+def main() -> None:
+ """Convert NumPy and DataFrame values at the vector store boundary."""
+ numpy_vector = np.arange(DIMENSIONS, dtype=np.float32) / np.float32(DIMENSIONS)
+ serialized_numpy = {"record_id": "numpy-1", "vector": numpy_vector.tolist()}
+ restored_numpy = decode_numpy_record(serialized_numpy)
+
+ print(f"Serialized vector type: {type(serialized_numpy['vector']).__name__}")
+ print(f"Restored vector type: {type(restored_numpy.vector).__name__} ({restored_numpy.vector.dtype})")
+
+ frame = pd.DataFrame({
+ "id": ["one", "two"],
+ "text": ["First record", "Second record"],
+ "vector": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
+ })
+ dataframe_rows = cast(list[dict[str, Any]], frame.to_dict(orient="records"))
+ restored_frame = pd.DataFrame.from_records(dataframe_rows)
+
+ print(f"Rows passed to batch upsert: {dataframe_rows}")
+ print("Restored DataFrame:")
+ print(restored_frame)
+
+
+if __name__ == "__main__":
+ main()
+
+
+"""
+Sample output:
+Serialized vector type: list
+Restored vector type: ndarray (float32)
+Rows passed to batch upsert: [
+ {'id': 'one', 'text': 'First record', 'vector': [0.1, 0.2, 0.3]},
+ {'id': 'two', 'text': 'Second record', 'vector': [0.4, 0.5, 0.6]}
+]
+Restored DataFrame:
+ id text vector
+0 one First record [0.1, 0.2, 0.3]
+1 two Second record [0.4, 0.5, 0.6]
+"""
diff --git a/python/samples/02-agents/vector_stores/vector_store_models.py b/python/samples/02-agents/vector_stores/vector_store_models.py
new file mode 100644
index 00000000000..d0d79575198
--- /dev/null
+++ b/python/samples/02-agents/vector_stores/vector_store_models.py
@@ -0,0 +1,174 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+from __future__ import annotations
+
+# Run with: uv run samples/02-agents/vector_stores/vector_store_models.py
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Annotated, Any, cast
+
+import msgspec
+from agent_framework import (
+ VectorStoreCollectionDefinition,
+ VectorStoreField,
+ register_vectorstoremodel,
+ vectorstoremodel,
+)
+from pydantic import BaseModel
+
+"""This sample demonstrates the choices for defining vector store models.
+
+When you own the model, use the representation that best fits the rest of your
+application: a dataclass, Pydantic model, msgspec struct, plain class, or
+dictionary. ``@vectorstoremodel`` adds vector store metadata without requiring
+the application to adopt one specific modeling library.
+
+When another package or team owns the model, adapt it instead of rewriting it.
+Use ``register_vectorstoremodel`` with an explicit definition and codecs for a
+model type, or pass a loose collection definition directly for dictionaries
+and other schema-less records.
+
+For NumPy vectors and DataFrame row containers, see
+[optimized_data_formats.py](optimized_data_formats.py).
+
+The examples are ordered using indicative serialization measurements, but the
+right choice depends on validation, memory, interoperability, and the broader
+application, not only handler round-trip speed.
+"""
+
+
+# 1. Custom codecs can be registered in two equivalent ways.
+# The decorator creates the field definition from annotations and registers it with the codecs.
+def encode_legacy_faq(faq: LegacyFaq) -> Mapping[str, Any]:
+ """Convert a legacy FAQ to logical vector model fields."""
+ return {"id": faq.faq_number, "question": faq.prompt}
+
+
+def decode_legacy_faq(record: Mapping[str, Any]) -> LegacyFaq:
+ """Restore a legacy FAQ from logical vector model fields."""
+ return LegacyFaq(faq_number=cast(str, record["faq_number"]), prompt=cast(str, record["prompt"]))
+
+
+@vectorstoremodel(
+ collection_name="legacy-faqs",
+ encoder=encode_legacy_faq,
+ decoder=decode_legacy_faq,
+)
+@dataclass
+class LegacyFaq:
+ faq_number: Annotated[str, VectorStoreField("key", storage_name="id")]
+ prompt: Annotated[str, VectorStoreField("data", storage_name="question")]
+
+
+# The helper performs the same registration when the definition is supplied separately.
+@dataclass
+class LegacyArticle:
+ article_id: int
+ heading: str
+
+
+def encode_legacy_article(article: LegacyArticle) -> Mapping[str, Any]:
+ """Convert a legacy article to logical vector model fields."""
+ return {"id": str(article.article_id), "title": article.heading}
+
+
+def decode_legacy_article(record: Mapping[str, Any]) -> LegacyArticle:
+ """Restore a legacy article from logical vector model fields."""
+ return LegacyArticle(article_id=int(record["id"]), heading=cast(str, record["title"]))
+
+
+legacy_definition = VectorStoreCollectionDefinition(
+ [
+ VectorStoreField("key", name="id", storage_name="article_id"),
+ VectorStoreField("data", name="title", storage_name="heading"),
+ ],
+ collection_name="legacy-articles",
+)
+register_vectorstoremodel(
+ LegacyArticle,
+ definition=legacy_definition,
+ encoder=encode_legacy_article,
+ decoder=decode_legacy_article,
+)
+
+
+# 2. Plain dictionaries can be used as models; in that case, we just need the collection-specific definition.
+dictionary_definition = VectorStoreCollectionDefinition(
+ [
+ VectorStoreField("key", name="id"),
+ VectorStoreField("data", name="text"),
+ VectorStoreField("vector", name="vector", dimensions=3),
+ ],
+ collection_name="dictionary-records",
+)
+
+
+# 3. Plain classes use their annotated constructor parameters.
+@vectorstoremodel(collection_name="notes")
+class Note:
+ def __init__(
+ self,
+ note_id: Annotated[str, VectorStoreField("key")],
+ text: Annotated[str, VectorStoreField("data")],
+ ) -> None:
+ self.note_id = note_id
+ self.text = text
+
+
+# 4. msgspec structs use the default registered codec.
+@vectorstoremodel(collection_name="documents")
+class Document(msgspec.Struct):
+ document_id: Annotated[str, VectorStoreField("key")]
+ title: Annotated[str, VectorStoreField("data")]
+ vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None
+
+
+# 5. Dataclasses use the default registered codec.
+@vectorstoremodel(collection_name="hotels")
+@dataclass
+class Hotel:
+ hotel_id: Annotated[str, VectorStoreField("key")]
+ name: Annotated[str, VectorStoreField("data", is_indexed=True)]
+ description: Annotated[
+ str | list[float] | None,
+ VectorStoreField("vector", dimensions=3, distance_function="cosine_similarity"),
+ ] = None
+
+
+# 6. Pydantic models provide validation with additional round-trip cost.
+@vectorstoremodel(collection_name="products")
+class Product(BaseModel):
+ product_id: Annotated[str, VectorStoreField("key")]
+ name: Annotated[str, VectorStoreField("data", is_full_text_indexed=True)]
+ vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None
+
+
+def main() -> None:
+ """Inspect model definitions and registration choices."""
+ model_definitions = (
+ ("LegacyFaq", cast(VectorStoreCollectionDefinition, vars(LegacyFaq)["__vectorstoremodel_definition__"])),
+ ("LegacyArticle", legacy_definition),
+ ("dict", dictionary_definition),
+ ("Note", cast(VectorStoreCollectionDefinition, vars(Note)["__vectorstoremodel_definition__"])),
+ ("Document", cast(VectorStoreCollectionDefinition, vars(Document)["__vectorstoremodel_definition__"])),
+ ("Hotel", cast(VectorStoreCollectionDefinition, vars(Hotel)["__vectorstoremodel_definition__"])),
+ ("Product", cast(VectorStoreCollectionDefinition, vars(Product)["__vectorstoremodel_definition__"])),
+ )
+ for model_name, definition in model_definitions:
+ print(f"{model_name}: collection={definition.collection_name}, fields={definition.names}")
+
+
+if __name__ == "__main__":
+ main()
+
+
+"""
+Sample output:
+LegacyFaq: collection=legacy-faqs, fields=['faq_number', 'prompt']
+LegacyArticle: collection=legacy-articles, fields=['id', 'title']
+dict: collection=dictionary-records, fields=['id', 'text', 'vector']
+Note: collection=notes, fields=['note_id', 'text']
+Document: collection=documents, fields=['document_id', 'title', 'vector']
+Hotel: collection=hotels, fields=['hotel_id', 'name', 'description']
+Product: collection=products, fields=['product_id', 'name', 'vector']
+"""
diff --git a/python/samples/03-workflows/orchestrations/README.md b/python/samples/03-workflows/orchestrations/README.md
index eb7d8d477f1..9c18f947bf9 100644
--- a/python/samples/03-workflows/orchestrations/README.md
+++ b/python/samples/03-workflows/orchestrations/README.md
@@ -71,6 +71,7 @@ from agent_framework.orchestrations import (
| Magentic Workflow | [magentic.py](./magentic.py) | Orchestrate multiple agents with a Magentic manager and streaming |
| Magentic + Human Plan Review | [magentic_human_plan_review.py](./magentic_human_plan_review.py) | Human reviews or updates the plan before execution |
| Magentic + Checkpoint Resume | [magentic_checkpoint.py](./magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints |
+| Magentic + Custom Manager Prompts | [magentic_custom_prompts.py](./magentic_custom_prompts.py) | Override the manager's planning, ledger, and final answer prompts |
| Magentic Orchestration as Agent | [magentic_workflow_as_agent.py](../agents/magentic_workflow_as_agent.py) | Build a MagenticBuilder workflow and reuse it as an agent |
## Tips
diff --git a/python/samples/03-workflows/orchestrations/magentic_custom_prompts.py b/python/samples/03-workflows/orchestrations/magentic_custom_prompts.py
new file mode 100644
index 00000000000..1b3784ae460
--- /dev/null
+++ b/python/samples/03-workflows/orchestrations/magentic_custom_prompts.py
@@ -0,0 +1,214 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+import asyncio
+import os
+from typing import cast
+
+from agent_framework import Agent, AgentResponseUpdate, Message, WorkflowEvent
+from agent_framework.foundry import FoundryChatClient
+from agent_framework.orchestrations import MagenticBuilder
+from agent_framework_orchestrations import MagenticOrchestrator
+from azure.identity import AzureCliCredential
+from dotenv import load_dotenv
+
+"""
+Sample: Magentic Orchestration with Custom Manager Prompts
+
+The `StandardMagenticManager` drives planning, replanning, progress tracking, and the final
+answer with a set of built-in prompts. Every one of them can be replaced through
+`MagenticBuilder`, which is useful when the orchestration has to follow a house style, a
+domain vocabulary, or a fixed report format.
+
+Overridable prompts and the placeholders available to each one:
+
+| Builder argument | Placeholders | Used for |
+| ---------------------------------- | ---------------------------------- | --------------------------------- |
+| `task_ledger_facts_prompt` | `{task}` | Initial fact sheet |
+| `task_ledger_plan_prompt` | `{team}` | Initial plan |
+| `task_ledger_full_prompt` | `{task}`, `{team}`, `{facts}`, `{plan}` | Combined ledger shown to the team |
+| `task_ledger_facts_update_prompt` | `{task}`, `{old_facts}` | Fact sheet refresh on replan |
+| `task_ledger_plan_update_prompt` | `{team}` | New plan on replan |
+| `progress_ledger_prompt` | `{task}`, `{team}`, `{names}` | Per-round progress decision |
+| `final_answer_prompt` | `{task}` | Final synthesized answer |
+
+A prompt is formatted with `str.format`, so any literal brace in a custom prompt must be
+doubled (`{{`, `}}`). An override may omit any available placeholder, but any placeholder it
+uses must have a name listed above. `progress_ledger_prompt` is the one override to treat with
+care: its response is parsed as JSON, so a replacement must keep the same schema as the
+built-in prompt. This sample leaves it at the default and overrides the six prompts that shape
+the ledger and the final report. Note that the replan prompts have to be overridden alongside
+the initial ones: a stall makes the manager rebuild the fact sheet and the plan, and leaving
+the replan prompts at their defaults would silently drop the custom format mid-run.
+
+Prerequisites:
+- FOUNDRY_PROJECT_ENDPOINT must be your Microsoft Foundry Agent Service (V2) project endpoint.
+- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
+- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
+"""
+
+load_dotenv()
+
+FACTS_PROMPT = """You are the planning lead of an incident review board.
+
+Request under review:
+
+{task}
+
+Produce a fact sheet using exactly these headings, and nothing else:
+
+ 1. CONFIRMED SIGNALS
+ 2. SIGNALS TO COLLECT
+ 3. SIGNALS TO DERIVE
+ 4. WORKING HYPOTHESES
+
+Every entry must be a single line starting with "- ". Do not propose next steps yet.
+"""
+
+PLAN_PROMPT = """The review board is staffed as follows:
+
+{team}
+
+Write the investigation plan as numbered steps. Each step must be one line in the form:
+
+ . [] ->
+
+Use at most five steps and only involve a team member when their expertise is required.
+"""
+
+FACTS_UPDATE_PROMPT = """The investigation has stalled on this request:
+
+{task}
+
+Rewrite the fact sheet below with everything learned since it was written, keeping the same
+four headings and the same one-line "- " entries. Move confirmed items out of the hypothesis
+section and add at least one new working hypothesis with its reasoning.
+
+Previous fact sheet:
+
+{old_facts}
+"""
+
+PLAN_UPDATE_PROMPT = """State in one sentence why the previous plan stalled, then write a new
+investigation plan for this board:
+
+{team}
+
+Keep the numbered one-line step format:
+
+ . [] ->
+
+Use at most five steps and make each one avoid the failure you just named.
+"""
+
+FULL_LEDGER_PROMPT = """INCIDENT REVIEW BRIEF
+=====================
+
+Request:
+{task}
+
+Board:
+{team}
+
+Fact sheet:
+{facts}
+
+Investigation plan:
+{plan}
+"""
+
+FINAL_ANSWER_PROMPT = """The investigation of the following request is complete:
+
+{task}
+
+Write the closing report for the incident review board with these sections:
+
+ SUMMARY - two sentences, no jargon.
+ FINDINGS - bullet list, each with the evidence it rests on.
+ RECOMMENDATION - a single actionable sentence.
+
+Address the reader directly and do not mention the investigation process itself.
+"""
+
+
+async def main() -> None:
+ client = FoundryChatClient(
+ project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
+ model=os.environ["FOUNDRY_MODEL"],
+ credential=AzureCliCredential(),
+ )
+
+ log_analyst = Agent(
+ name="LogAnalyst",
+ description="Reads service logs and metrics to reconstruct what happened during an incident",
+ instructions=(
+ "You are a log analyst. Reconstruct incident timelines from the evidence you are given "
+ "and state plainly when evidence is missing."
+ ),
+ client=client,
+ )
+
+ reliability_engineer = Agent(
+ name="ReliabilityEngineer",
+ description="Explains failure modes and proposes mitigations for distributed systems",
+ instructions="You are a reliability engineer. Explain likely failure modes and propose mitigations.",
+ client=client,
+ )
+
+ manager_agent = Agent(
+ name="MagenticManager",
+ description="Orchestrator that runs the incident review board",
+ instructions="You coordinate a team to complete complex tasks efficiently.",
+ client=client,
+ )
+
+ workflow = MagenticBuilder(
+ participants=[log_analyst, reliability_engineer],
+ intermediate_output_from=[log_analyst, reliability_engineer],
+ manager_agent=manager_agent,
+ task_ledger_facts_prompt=FACTS_PROMPT,
+ task_ledger_plan_prompt=PLAN_PROMPT,
+ task_ledger_full_prompt=FULL_LEDGER_PROMPT,
+ task_ledger_facts_update_prompt=FACTS_UPDATE_PROMPT,
+ task_ledger_plan_update_prompt=PLAN_UPDATE_PROMPT,
+ final_answer_prompt=FINAL_ANSWER_PROMPT,
+ max_round_count=8,
+ max_stall_count=2,
+ ).build()
+
+ task = (
+ "A checkout service returned HTTP 503 for 12 minutes after a deployment. Error rates spiked "
+ "only on the two pods that were rescheduled onto a new node pool, and the database connection "
+ "pool reported saturation for the same window. Determine the most likely root cause and how to "
+ "prevent a recurrence."
+ )
+
+ print(f"\nTask: {task}\n")
+
+ last_message_id: str | None = None
+ output_event: WorkflowEvent | None = None
+ async for event in workflow.run(task, stream=True):
+ if event.type in ("intermediate", "output"):
+ if event.executor_id == MagenticOrchestrator.MANAGER_NAME:
+ output_event = event
+ else:
+ update = cast(AgentResponseUpdate, event.data)
+ if update.message_id != last_message_id:
+ if last_message_id is not None:
+ print("\n")
+ print(f"- {event.executor_id}:", end=" ", flush=True)
+ last_message_id = update.message_id
+ print(update, end="", flush=True)
+
+ elif event.type == "magentic_orchestrator" and isinstance(event.data.content, Message):
+ # The task ledger rendered here follows FULL_LEDGER_PROMPT rather than the built-in layout.
+ print(f"\n[{event.data.event_type.name}]\n{event.data.content.text}")
+
+ if not output_event:
+ raise RuntimeError("Workflow did not produce a final output event.")
+
+ print("\n\nFinal report (shaped by FINAL_ANSWER_PROMPT):")
+ print(cast(AgentResponseUpdate, output_event.data).text)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml
index f151596f38a..a70ff849d80 100644
--- a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml
+++ b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml
@@ -8,7 +8,7 @@ dependencies = [
"agent-framework-hosting",
"agent-framework-hosting-responses",
"aiohttp>=3.13.5",
- "fastapi>=0.115.0,<0.138.1",
+ "fastapi>=0.115.0,<0.142.0",
"hypercorn>=0.17",
]
diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml
index 0a00e82d248..7bfb2fa1190 100644
--- a/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml
+++ b/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml
@@ -9,7 +9,7 @@ dependencies = [
"agent-framework-hosting-responses",
"azure-identity",
"aiohttp>=3.13.5",
- "fastapi>=0.115.0,<0.138.1",
+ "fastapi>=0.115.0,<0.142.0",
"hypercorn>=0.17",
]
diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml
index 53f9b0cbe0c..205456edd16 100644
--- a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml
+++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml
@@ -8,7 +8,7 @@ dependencies = [
"agent-framework-hosting",
"agent-framework-hosting-responses",
"aiohttp>=3.13.5",
- "fastapi>=0.115.0,<0.138.1",
+ "fastapi>=0.115.0,<0.142.0",
"hypercorn>=0.17",
]
diff --git a/python/samples/04-hosting/af-hosting/local_telegram/app.py b/python/samples/04-hosting/af-hosting/local_telegram/app.py
index 922e9ffaf8f..be3db48c1b7 100644
--- a/python/samples/04-hosting/af-hosting/local_telegram/app.py
+++ b/python/samples/04-hosting/af-hosting/local_telegram/app.py
@@ -5,7 +5,7 @@
# "agent-framework-hosting",
# "agent-framework-hosting-telegram",
# "aiogram>=3.29.1,<4",
-# "fastapi>=0.115.0,<0.138.1",
+# "fastapi>=0.115.0,<0.142.0",
# "hypercorn>=0.17",
# ]
# ///
diff --git a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml
index 525b433bcd4..49e44ce3e3b 100644
--- a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml
+++ b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml
@@ -8,7 +8,7 @@ dependencies = [
"agent-framework-hosting",
"agent-framework-hosting-telegram",
"aiogram>=3.29.1,<4",
- "fastapi>=0.115.0,<0.138.1",
+ "fastapi>=0.115.0,<0.142.0",
"hypercorn>=0.17",
]
diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md
index c39f180f404..0eab3777fb2 100644
--- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md
+++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md
@@ -4,7 +4,7 @@ An [Agent Framework](https://github.com/microsoft/agent-framework) agent that us
## Creating a Foundry Toolbox
-You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolboxes_crud.py).
+You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/toolboxes/sample_toolboxes_crud.py).
You can also create a Foundry Toolbox in the Foundry portal. Read more about it [in the Foundry toolbox documentation](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox).
@@ -21,13 +21,13 @@ This sample consumes a toolbox over its MCP endpoint. It bundles a [`toolbox.yam
You can connect to MCP servers in Foundry Toolbox that use different authentication methods. This sample demonstrates the following authentication methods:
-- [**No authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#5-mcp-no-auth): The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs`
-- [**Key-based authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#4-mcp-key-auth-github): The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication.
-- [**OAuth2 authentication (managed)**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#6-mcp-oauth-managed-connector): The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication.
-- [**Agent identity authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#8-mcp-agent-identity): The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` ([Azure Language MCP server](https://learn.microsoft.com/en-us/azure/ai-services/language-service/concepts/foundry-tools-agents#azure-language-mcp-server-preview)) with agent identity for authentication.
-- [**Entra Pass-through authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#13-mcp-oauth-entra-passthrough): The tool requires an Entra pass-through token to authenticate; Foundry forwards the calling user's Entra token to the MCP server. Sample MCP server: the [Microsoft Foundry MCP server](https://learn.microsoft.com/en-us/azure/foundry/mcp/get-started?view=foundry&tabs=user), which exposes Foundry model-catalog, evaluation, agent, and session tools and requires only that the caller have access to the Foundry project (no extra license).
+- [**No authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-unauthenticated.md): The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs`
+- [**Key-based authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-key-auth.md): The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication.
+- [**OAuth2 authentication (managed)**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-oauth-managed.md): The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication.
+- [**Agent identity authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-microsoft-entra.md): The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` ([Azure Language MCP server](https://learn.microsoft.com/en-us/azure/ai-services/language-service/concepts/foundry-tools-agents#azure-language-mcp-server-preview)) with agent identity for authentication.
+- [**Entra Pass-through authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-user-entra-token.md): The tool requires an Entra pass-through token to authenticate; Foundry forwards the calling user's Entra token to the MCP server. Sample MCP server: the [Microsoft Foundry MCP server](https://learn.microsoft.com/en-us/azure/foundry/mcp/get-started?view=foundry&tabs=user), which exposes Foundry model-catalog, evaluation, agent, and session tools and requires only that the caller have access to the Foundry project (no extra license).
-There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md).
+There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/README.md).
### Finding the Entra audience for an MCP server
@@ -131,8 +131,8 @@ Follow the prompts to configure your Foundry project and model deployment. If yo
> [!TIP]
> If you use GitHub Copilot for Azure to scaffold a hosted agent that consumes this toolbox, the following skill references describe the same endpoint contract (env var, headers, MCP protocol, citation patterns, and troubleshooting) that the agent must implement:
>
-> - [Toolbox reference](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/foundry-agent/create/references/toolbox-reference.md) â endpoint format, MCP protocol, OAuth consent handling, citation patterns, and troubleshooting.
-> - [Use toolbox in a hosted agent](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/foundry-agent/create/references/use-toolbox-in-hosted-agent.md) â endpoint resolution, env-var contract, payload shape, code integration patterns, and tracing.
+> - [Toolbox overview](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugins/azure-skills/skills/microsoft-foundry/foundry-agent/toolbox/toolbox.md) â toolbox concept, API shape and schema, versions, endpoints, and MCP protocol.
+> - [Use toolbox in a hosted agent](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugins/azure-skills/skills/microsoft-foundry/foundry-agent/create/references/use-toolbox-in-hosted-agent.md) â endpoint resolution, env-var contract, payload shape, code integration patterns, and tracing.
The agent reads the toolbox's MCP endpoint from `TOOLBOX_ENDPOINT`. Create the toolbox once from the bundled [`toolbox.yaml`](toolbox.yaml):
@@ -223,7 +223,7 @@ Press **F5** to start the agent in debug mode. The agent host will start on `htt
### Creating a Foundry Toolbox
-You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolboxes_crud.py).
+You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/toolboxes/sample_toolboxes_crud.py).
You can also create a Foundry Toolbox in the Foundry portal. Read more about it [in the Foundry toolbox documentation](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox).
diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/README.md b/python/samples/05-end-to-end/evaluation/foundry_evals/README.md
index c423d2b600b..cf33fa28cd0 100644
--- a/python/samples/05-end-to-end/evaluation/foundry_evals/README.md
+++ b/python/samples/05-end-to-end/evaluation/foundry_evals/README.md
@@ -15,15 +15,24 @@ These samples demonstrate evaluating agent-framework agents using Microsoft Foun
### `evaluate_agent_sample.py` â Dataset Evaluation (Path 3)
-The dev inner loop. Two patterns from simplest to most control:
-
-1. **`evaluate_agent()`** â One call: runs agent â converts â evaluates
-2. **`FoundryEvals.evaluate()`** â Run agent yourself, convert with `AgentEvalConverter`, inspect/modify, then evaluate
+The dev inner loop. Pass existing responses or let `evaluate_agent()` run the
+agent against test queries before submitting provider-neutral `EvalItem` data
+to Foundry.
```bash
uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py
```
+### `evaluate_tool_calls_sample.py` â Explicit Eval Items
+
+For more control, run the agent yourself, construct public `EvalItem` instances
+from the conversation and typed tools, inspect or modify them, and pass them to
+`FoundryEvals.evaluate()`.
+
+```bash
+uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py
+```
+
### `evaluate_traces_sample.py` â Trace & Response Evaluation (Path 1)
Evaluate what already happened â zero changes to agent code:
@@ -71,5 +80,5 @@ Create a `.env` file with configuration as in the `.env.example` file in this fo
- **"I want to test my agent during development"** â `evaluate_agent_sample.py`, Pattern 1
- **"I want to evaluate past agent runs"** â `evaluate_traces_sample.py`
-- **"I want to inspect/modify eval data before submitting"** â `evaluate_agent_sample.py`, Pattern 2
+- **"I want to inspect/modify eval data before submitting"** â `evaluate_tool_calls_sample.py`
- **"I want to score against a custom rubric I created in Foundry"** â `evaluate_with_rubric_sample.py`
diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py
index d9fcf051ce5..019c0709d79 100644
--- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py
+++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py
@@ -13,7 +13,7 @@
import asyncio
import os
-from agent_framework import Agent, AgentEvalConverter
+from agent_framework import Agent, EvalItem, Message, tool
from agent_framework.foundry import FoundryChatClient, FoundryEvals
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -21,6 +21,7 @@
load_dotenv()
+@tool
def get_weather(location: str) -> str:
"""Get the current weather for a location."""
weather_data = {
@@ -31,6 +32,7 @@ def get_weather(location: str) -> str:
return weather_data.get(location.lower(), f"Weather data not available for {location}")
+@tool
def get_flight_price(origin: str, destination: str) -> str:
"""Get the price of a flight between two cities."""
return f"Flights from {origin} to {destination}: $450 round-trip"
@@ -65,7 +67,10 @@ async def main() -> None:
print(f"Query: {q}")
print(f"Response: {response.text[:100]}...")
- item = AgentEvalConverter.to_eval_item(query=q, response=response, agent=agent)
+ item = EvalItem(
+ conversation=[Message("user", [q]), *response.messages],
+ tools=[get_weather, get_flight_price],
+ )
items.append(item)
print(f" Has tools: {item.tools is not None}")
diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md
index 7250e424f62..739329a1250 100644
--- a/python/samples/AGENTS.md
+++ b/python/samples/AGENTS.md
@@ -10,7 +10,8 @@ python/samples/
âââ 01-get-started/ # Progressive tutorial (steps 01â07)
âââ 02-agents/ # Deep-dive concept samples
â âââ tools/ # Tool patterns (function, approval, schema, etc.)
-â âââ middleware/ # One file per middleware concept
+â âââ vector_stores/ # Vector model schemas and registration
+â âââ middleware/ # One file per middleware concept
â âââ conversations/ # Thread, storage, suspend/resume
â âââ providers/ # One sub-folder per provider (azure_ai/, openai/, etc.)
â âââ context_providers/ # Memory & context injection
diff --git a/python/uv.lock b/python/uv.lock
index bc3659bd75a..529311c8725 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -177,11 +177,11 @@ dev = [
{ name = "pytest-timeout", specifier = "==2.4.0" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" },
{ name = "rich", specifier = ">=13.7.1,<16.0.0" },
- { name = "ruff", specifier = "==0.16.3" },
+ { name = "ruff", specifier = "==0.16.4" },
{ name = "tomli", specifier = "==2.4.1" },
- { name = "ty", specifier = "==0.0.72" },
- { name = "uv", specifier = "==0.12.5" },
- { name = "zuban", specifier = "==0.9.1" },
+ { name = "ty", specifier = "==0.0.75" },
+ { name = "uv", specifier = "==0.12.6" },
+ { name = "zuban", specifier = "==0.9.2" },
]
test = [
{ name = "agent-hooks-sdk", specifier = ">=0.1.0a4,<0.2" },
@@ -235,7 +235,7 @@ requires-dist = [
{ name = "ag-ui-a2ui-toolkit", marker = "extra == 'a2ui'", specifier = ">=0.0.4" },
{ name = "ag-ui-protocol", specifier = ">=0.1.19,<0.2" },
{ name = "agent-framework-core", editable = "packages/core" },
- { name = "fastapi", specifier = ">=0.121.0,<0.140.0" },
+ { name = "fastapi", specifier = ">=0.121.0,<0.142.0" },
{ name = "httpx", specifier = ">=0.28.1,<1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" },
{ name = "sse-starlette", specifier = ">=3.4.5,<4" },
@@ -576,7 +576,7 @@ dev = [
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" },
- { name = "fastapi", specifier = ">=0.115.0,<0.138.1" },
+ { name = "fastapi", specifier = ">=0.115.0,<0.142.0" },
{ name = "openai", specifier = ">=2.45.0,<4" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" },
{ name = "pytest", marker = "extra == 'all'", specifier = "==9.1.1" },
@@ -692,7 +692,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
- { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "==1.0.2" },
+ { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "==1.0.11" },
]
[[package]]
@@ -767,7 +767,7 @@ requires-dist = [
[package.metadata.requires-dev]
test = [
- { name = "fastapi", specifier = ">=0.115.0,<0.138.1" },
+ { name = "fastapi", specifier = ">=0.115.0,<0.142.0" },
{ name = "httpx", specifier = ">=0.28.1" },
]
@@ -881,10 +881,10 @@ dev = [
{ name = "pyright", specifier = "==1.1.411" },
{ name = "pytest", specifier = "==9.1.1" },
{ name = "rich", specifier = ">=13.7.1,<15.0.0" },
- { name = "ruff", specifier = "==0.16.3" },
+ { name = "ruff", specifier = "==0.16.4" },
{ name = "tomli", specifier = "==2.4.1" },
{ name = "tomli-w", specifier = "==1.2.0" },
- { name = "uv", specifier = "==0.12.5" },
+ { name = "uv", specifier = "==0.12.6" },
]
tau2 = [{ name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" }]
@@ -2488,7 +2488,7 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.138.0"
+version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2497,9 +2497,9 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
@@ -2835,19 +2835,15 @@ wheels = [
[[package]]
name = "github-copilot-sdk"
-version = "1.0.2"
+version = "1.0.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/1f/2c/3d3ecfe500c0ba7d3127737b1aa22f19ff1a19e6e86360bfdca3f02a2c09/github_copilot_sdk-1.0.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:856dfc8370f36f6efd8a2aa1dd40f82c1a6d0573d0577eaff1f6affb73ed29ad", size = 97329153, upload-time = "2026-06-18T00:56:20.653Z" },
- { url = "https://files.pythonhosted.org/packages/3d/ab/d4ab9320e50a1381d436401f75f8ad54fa57541324451ef2a96db6258464/github_copilot_sdk-1.0.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c78c610c3fd7be82ab69a76fb21e04eb89482072e07e31ad1ca63c893aad0c6d", size = 90809903, upload-time = "2026-06-18T00:56:25.128Z" },
- { url = "https://files.pythonhosted.org/packages/24/33/880d681e5d661f8c66ac02c0b17091538ec716b62534a0d08859d48f7f99/github_copilot_sdk-1.0.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:32a844e7fa9644614f9092eeca853368debfed527baaaebfd1a4d4ee89cbdcbd", size = 99564035, upload-time = "2026-06-18T00:56:29.401Z" },
- { url = "https://files.pythonhosted.org/packages/d5/44/ce7485fada7a96ece22d7d0a0a18804d09dca2911b544f2fd90f4d7c02ff/github_copilot_sdk-1.0.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dd1faaaf896a7f97d9c36a1a40e968132e141019ce5a11c4666b089b7d6ea8cd", size = 97202424, upload-time = "2026-06-18T00:56:33.598Z" },
- { url = "https://files.pythonhosted.org/packages/76/90/755c9908a9bbdeb76ad3361d9e23c89ce7520c673745166a5ee2103443d1/github_copilot_sdk-1.0.2-py3-none-win_amd64.whl", hash = "sha256:d932666bba33a840421a183079141ea6b7a78ce4f84d30fb1ad035ee00b464e4", size = 93640286, upload-time = "2026-06-18T00:56:37.73Z" },
- { url = "https://files.pythonhosted.org/packages/f5/74/22204ba7547f4ea849993920240325cdb9851df0177cc808cb20ca04ef5a/github_copilot_sdk-1.0.2-py3-none-win_arm64.whl", hash = "sha256:7a7d99a2fb1c2fb1c05753110961fb99489201fc91a16be5297ef1c2ca2333f2", size = 92382516, upload-time = "2026-06-18T00:56:41.148Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ac/175cbb71fe637d963a885248a421040c9d500ea6390ed3b88bc68ecf51ca/github_copilot_sdk-1.0.11-py3-none-any.whl", hash = "sha256:6f664c7b843c34ab5a7455f7effb4d339eae75969d2b72f43c2e6214c9c637dc", size = 486719, upload-time = "2026-08-14T16:12:20.01Z" },
]
[[package]]
@@ -6857,27 +6853,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.16.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
- { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
- { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
- { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
- { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
- { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
- { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
- { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
- { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
- { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
- { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
- { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
- { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
- { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
- { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
- { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
- { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
+version = "0.16.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
+ { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
+ { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
+ { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
+ { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
]
[[package]]
@@ -7536,27 +7532,27 @@ wheels = [
[[package]]
name = "ty"
-version = "0.0.72"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" },
- { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" },
- { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" },
- { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" },
- { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" },
- { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" },
- { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" },
- { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" },
- { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" },
- { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" },
- { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" },
- { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" },
- { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" },
- { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" },
- { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" },
- { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" },
- { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" },
+version = "0.0.75"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" },
+ { url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" },
+ { url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" },
+ { url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" },
+ { url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" },
+ { url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" },
+ { url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" },
]
[[package]]
@@ -7621,28 +7617,28 @@ wheels = [
[[package]]
name = "uv"
-version = "0.12.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" },
- { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" },
- { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" },
- { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" },
- { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" },
- { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" },
- { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" },
- { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" },
- { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" },
- { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" },
- { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" },
- { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" },
- { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" },
- { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" },
- { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" },
- { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" },
- { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" },
- { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" },
+version = "0.12.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/26/c2cc420ebadf9a3d3170b6949d21071e2a50761e24b7d352ed2cf9d86de5/uv-0.12.6.tar.gz", hash = "sha256:7c687a6c88b4606b08cd27de29557ee0bf2fff6d2d946e9f1954e8c01745e0b1", size = 7117263, upload-time = "2026-08-25T19:40:02.286Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/94/5326c129967cee589cae13e5aea1a2be0d34b3590ec52cb3fb8693f9ca75/uv-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:348c35bfe137c539ff8c785e2c98bfb3ad7a2077699abf0b38b3b729386edce7", size = 23081229, upload-time = "2026-08-25T19:38:55.356Z" },
+ { url = "https://files.pythonhosted.org/packages/20/8d/dc29316192b7f449ddb0f0796c3f169aa4870b62dc65015d2e3305eecce4/uv-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:596e24f75b8b81d602228d00c4d7674b53013e94f04e22155f2c5486c3052438", size = 21296317, upload-time = "2026-08-25T19:38:59.08Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/5d/4c41b08b4e09729dd50f08b55238d5248a7bfb1a9f7cfba1c162046cfa1c/uv-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55bc16b317b2d6044c402e3b1f2c6a8daa031407e9efa7ecbf5348ac0a1c0df5", size = 18115315, upload-time = "2026-08-25T19:39:02.354Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3c/fe2c8b17df5302ea438ac595b5978210bcde9b23adf4fff755e592574fbf/uv-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1226946a479a5ae9be4132dafb1a02303be2eb4d39e931d4310088d1c5e4d3a7", size = 22413322, upload-time = "2026-08-25T19:39:05.926Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9d/195a98472a4971fb8045889fa0f04960c89bec22afb42af1f438119e9a4a/uv-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:4782d51befb745b378e155aafa00976377536c2c28bf214cea2068fc351b9081", size = 22503804, upload-time = "2026-08-25T19:39:09.605Z" },
+ { url = "https://files.pythonhosted.org/packages/54/0c/3872d536cd809ffa87af6f3a116399857a48f2664ba4b21b38c5416535df/uv-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f4f135204aa5236c94348c073fafbb00a7c00c23c25a5ab517aff8e68127dc3", size = 22565554, upload-time = "2026-08-25T19:39:13.286Z" },
+ { url = "https://files.pythonhosted.org/packages/77/bc/0fe96033ef905a2729fe512c73c08e541fc0427724777f01c2b934e5cfdd/uv-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09ce6415e7bf2e9482f66be2d131312b96c72e2e9c4bba2f4084c67515309fed", size = 23136010, upload-time = "2026-08-25T19:39:16.87Z" },
+ { url = "https://files.pythonhosted.org/packages/73/91/21756319256364d350ba38f1b3a16bd64ff726865d2d6bb677633fce3cb6/uv-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0888a800bfb661c0054c871d04d0b6ef4b8986e01222854c13346371d2b22d", size = 24466274, upload-time = "2026-08-25T19:39:20.723Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/49/38883c7734c13d1bc03081ec59c7e9ddd595460ad2fcfbd1a8220e02aa55/uv-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb3907b001b0054e8c81621455fb7aca26b4aa1fead35b3165013aadc5eb7ebd", size = 24318234, upload-time = "2026-08-25T19:39:25.019Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6b/dbcffa319e640b3f38fc8764b99031773d4449725ca21854309e436c08f9/uv-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb1c4af10a1037d2e875ac86da32ea0df20a9623e11769d33515d14158cd3c2", size = 20927695, upload-time = "2026-08-25T19:39:28.77Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/cc/4bde66c453d37e40c5861931f75fb66d207328f5ccbb18d44f818c81d920/uv-0.12.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f8f11ae7d78092384679eac086210d63261af241c56d7c46fa4ded62400e56ee", size = 20273626, upload-time = "2026-08-25T19:39:32.384Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/1b/e0c92c3c81b32c7bb70e375b0d218ca6876100cf113765005332454c61f1/uv-0.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:c874e7077a7a9210531ee361843b7e6a50f8c71a45a3880aab289079042bfbaf", size = 23279870, upload-time = "2026-08-25T19:39:36.485Z" },
+ { url = "https://files.pythonhosted.org/packages/65/fc/d24268fa3a39de0d79494fdc8c85000b6ef5b015754fb71b53d5912a2d84/uv-0.12.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ca17e4d2213c4f756a37077776e93202107eb10a71d05e3f9f91f678edcb0239", size = 23375132, upload-time = "2026-08-25T19:39:40.13Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ed/5903a2c3dd56b1c77f97c2d3f1c114f6247db0718d60afb8d08b2c84efa4/uv-0.12.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de0c4119af8b7ec15f4d3630d7fb2d9b37fee752093e67a4365df395ffbbf3d7", size = 22276726, upload-time = "2026-08-25T19:39:43.786Z" },
+ { url = "https://files.pythonhosted.org/packages/47/51/d9a3032bd233a1562b3bc90bbb5cca2aa6fa9669f4c1056ebe351b2f09fe/uv-0.12.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b7f850c30395026aa024b72a8833133ed71a832fa843c6c59496d8f393cce726", size = 23759803, upload-time = "2026-08-25T19:39:47.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/b9/e91d756ce38a182e58b5731bcef66a428bc492facd0941cb46be14808830/uv-0.12.6-py3-none-win32.whl", hash = "sha256:99f1a686675befc705530e8cca4a7b066383bd4650967d410abf819ab52bc5ab", size = 20699250, upload-time = "2026-08-25T19:39:51.608Z" },
+ { url = "https://files.pythonhosted.org/packages/11/69/c23e5b30db494f9b6e62e8d34ee87da2673c071d4f1518f89e93d52d1e2a/uv-0.12.6-py3-none-win_amd64.whl", hash = "sha256:8fa8fe6d7f4d9a897dd30159426e712094727494fbb7ef435bba3f83030b9fd7", size = 18927528, upload-time = "2026-08-25T19:39:55.299Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/41/b5b131a9c96e0c9e129edf30b1daffb3d8f8fbc3e9fc087624ffdd4700ad/uv-0.12.6-py3-none-win_arm64.whl", hash = "sha256:8a93a8c142940c106375cc50743faa7c610b6194e2e9e8da925a61aefb0302e6", size = 20332543, upload-time = "2026-08-25T19:39:59.173Z" },
]
[[package]]
@@ -8103,19 +8099,19 @@ wheels = [
[[package]]
name = "zuban"
-version = "0.9.1"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/cf/7701477eab6244532447ff6132ea2af2c7aec2f1ef82db446d018aee7ad8/zuban-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bf9d76d87215ac06433016353c7a751d0bb570f5bd7065ee884003a7333e6d52", size = 11346895, upload-time = "2026-07-31T22:12:46.376Z" },
- { url = "https://files.pythonhosted.org/packages/70/d1/6db81a0e25b59431313d08c8ba7612708fa6134a7ef881f438b777ad25cf/zuban-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:710eeec6725dc55a268f86b09d9a72e2ca0a9852800d0a58a20124732426eedb", size = 11073232, upload-time = "2026-07-31T22:12:48.968Z" },
- { url = "https://files.pythonhosted.org/packages/1b/36/c7f7bb36d634387c9f7dddd0201760a8755b2e65b80697cf207ee62ff741/zuban-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d10b28ed050f50b1e0cfe2a6d236d18361376a822dd0d28f9ddd4768733ee5d", size = 28278516, upload-time = "2026-07-31T22:12:51.604Z" },
- { url = "https://files.pythonhosted.org/packages/7e/ff/32a9a8bd4c33a22abd3f7b2a1d2b21643627608ca51a59503268dd390646/zuban-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab65c8bd0bb89f4cd5be56ebf8c63223b68f8d125f6883b39c304c2ef245a5cf", size = 28571449, upload-time = "2026-07-31T22:12:54.905Z" },
- { url = "https://files.pythonhosted.org/packages/9d/30/9112460b7c069338b6f1262e4663b744a149ea58520022e867cb19f5c014/zuban-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7832d97411a005880f7e89588dc8a207cc2e17a3d3b7875aadf7e1553ae1f4a6", size = 29739155, upload-time = "2026-07-31T22:12:57.965Z" },
- { url = "https://files.pythonhosted.org/packages/d0/f9/5ab411dbcfc118934feb19d2a80dba79277d4be2ebf89554e97918c2a826/zuban-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf684202197630b4ba23eb236e037ed2627bbb4acdb34db8c7288e9668ad5d", size = 31614638, upload-time = "2026-07-31T22:13:01.03Z" },
- { url = "https://files.pythonhosted.org/packages/4b/67/bbbb52fc7bbb773bdfa4cf546f9c15136826c9c4a03beddb3686db2c7f7e/zuban-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67354f17d0e267c633dc8ae287c6c062d9b8f5f1ca83cc7cca85de7f0d88e686", size = 28455302, upload-time = "2026-07-31T22:13:03.819Z" },
- { url = "https://files.pythonhosted.org/packages/e9/c8/b57ac05d879c54e69947cd270e0da21fd76dda6cece135e528f2c459f9bf/zuban-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8a828388987d0d0e77160e9ae8e9fd1b6f65d071812b0004f6537c1a9d694a07", size = 28981446, upload-time = "2026-07-31T22:13:07.201Z" },
- { url = "https://files.pythonhosted.org/packages/3b/b1/2f6cfaa2a319e1c5b06c62ee2710c55416cc660a3fff83ab354654d5d721/zuban-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:25dee4561c8e8c9bb5da98c13e7ea5a1d9bbf2778f7c0f7251599f7051ef52ec", size = 29610910, upload-time = "2026-07-31T22:13:10.209Z" },
- { url = "https://files.pythonhosted.org/packages/ec/79/36b5187c5ac5b80516e6e0cdf240282a8c49e7ee8ae88a426d0b0328963c/zuban-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:387407431d876c0b9993c14e3aa2b2ccd19e443b1a7dde72a5f7f55e864118f1", size = 28894561, upload-time = "2026-07-31T22:13:12.991Z" },
- { url = "https://files.pythonhosted.org/packages/62/f1/5a27e21f534fb2349fa914aa688d431579e5f49b0841f958618821660a6d/zuban-0.9.1-py3-none-win32.whl", hash = "sha256:ccafab33ae98e0ae9a826010d954f367a4a8c76378c58c5bc75bdedceaa54e70", size = 10044309, upload-time = "2026-07-31T22:13:15.558Z" },
- { url = "https://files.pythonhosted.org/packages/b9/32/ca6d67180dcbc408c0ec6dd55915dd8471982dde4443dcd492d7d52610ec/zuban-0.9.1-py3-none-win_amd64.whl", hash = "sha256:c401b88742e8a501c68f4ec605d7ebc6a63401653c15f0173a76febe161bd53e", size = 10724603, upload-time = "2026-07-31T22:13:18.086Z" },
+version = "0.9.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/c0/1c395a08b2a7c48fbbb1398818d65813032ad0acfec5a841db0e7142a6ac/zuban-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8922df80834c2d5cb1e2798192dc85d40b4dc3fa3149e7b2b89dccf3cdc110d8", size = 11377755, upload-time = "2026-08-26T00:19:21.022Z" },
+ { url = "https://files.pythonhosted.org/packages/af/3c/b323802de4a86f71479b85d388294380205006362bca4414d3213469136a/zuban-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112c9bd639ec417fe7881a7d99bc34e35ac6ba6e0afd4f33caf0774ea8ae84fc", size = 11101402, upload-time = "2026-08-26T00:19:24.548Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3b/71ee867aebe9175de1db0208c2eeee7f294821545e6140c28e5ed9f21888/zuban-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8d65dafe086a8e73cbb8e6d7e3b234a7d2e64cf794d49c5ca0ca901a2880bea", size = 28451467, upload-time = "2026-08-26T00:19:28.898Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/93/7331f9f096b1c4a9d575ed6e86572cf6c5ac9eab5dc1c68d0d875e78d549/zuban-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8431a65ac7815af24e015d6f6c935518625e015e028b061899677a3d43c6349", size = 28687477, upload-time = "2026-08-26T00:19:33.102Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5a/de09de19a0ea8a4593632631c905751cf380cf8a92d1607632ad40c683ea/zuban-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d271a27a56ad89e3bb5942580aa903354c3861bfab29e5c655d44007d1d15a2", size = 29736764, upload-time = "2026-08-26T00:19:38.73Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/71/2e32dfafa5ac302b7b9cc48bbcfbc9da6bca221ccfd03da5b9d2a28240ee/zuban-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20a467e9d90f5dce53be3c760d9b65e768870a4e940a5256985f67155587eeb9", size = 31719649, upload-time = "2026-08-26T00:19:43.454Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/24/d437b54a088b2a35df948841d011a011ba5db59bdb4bad7c800dffcbbe2e/zuban-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a2c29e501de2f39690eadffab6369c9ac949bdab82aaab4c9fcbbfda0794d704", size = 28630746, upload-time = "2026-08-26T00:19:47.888Z" },
+ { url = "https://files.pythonhosted.org/packages/04/12/a132d0ad526d8986475344546b22acfea0bd6856bb293b70e691fa9bf5d5/zuban-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:23e34cd5d3c2aad476506fc2a0d3f4fd314e87bb03c873ec2c319497b06f16df", size = 29122179, upload-time = "2026-08-26T00:19:52.937Z" },
+ { url = "https://files.pythonhosted.org/packages/70/4e/ebbec923b02c69eee955cc65d227c4a1b6f5c6f2b509b92c9df02ad67e2b/zuban-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:40f4737d3f7c5926b986ebb4cb44767b2b72788072e833c49d2d3ad2f69ecbdb", size = 29624765, upload-time = "2026-08-26T00:19:57.778Z" },
+ { url = "https://files.pythonhosted.org/packages/db/7f/8baa60499029d57bc0dbc254d75469f7b7860654671d0bf3683bc6c56f05/zuban-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a83d3c1f12cd131774a4f89b3c0d750619e7035d02dcd4f668fb43e5ba2fea", size = 28964675, upload-time = "2026-08-26T00:20:02.558Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/d8/4a6f29958fa962c2b12ec32a0624708b9da93bec236ace0ac993632d05b1/zuban-0.9.2-py3-none-win32.whl", hash = "sha256:59d09caf2e488eb6d31a3bead1699349f66dafbfd595eb64bcd8e11b79d8e2d1", size = 10051150, upload-time = "2026-08-26T00:20:05.965Z" },
+ { url = "https://files.pythonhosted.org/packages/af/c2/123e0f3054f688039290766fdbdd4cba96834537e6fd3f6c95ae460858bd/zuban-0.9.2-py3-none-win_amd64.whl", hash = "sha256:9bc429ef78d3f21a6aeec9cddffe8748235ef2374ef318a91f4aa089cafba75c", size = 10730401, upload-time = "2026-08-26T00:20:08.97Z" },
]